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, Ordering};
7use std::sync::{Arc, Condvar, Mutex};
8use std::time::{Duration, Instant, 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::ignored::Group;
20use crate::inputs::{
21    Declared, Inferred, JsonFields, Resolved, TsConfig, manifest_for_dependents,
22    workspace_without_resolution,
23};
24use crate::paths::{self, Outputs, Pattern};
25
26pub fn digest_file(path: &Path) -> Result<String> {
27    let mut hasher = blake3::Hasher::new();
28    let mut reader = File::open(path)?;
29    let mut buffer = [0; 64 * 1024];
30    loop {
31        let count = reader.read(&mut buffer)?;
32        if count == 0 {
33            break;
34        }
35        hasher.update(&buffer[..count]);
36    }
37    Ok(hasher.finalize().to_hex().to_string())
38}
39
40/// Metadata that changes whenever a file's content can have changed: size,
41/// modification time and, on Unix, change time, inode and mode.
42#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
43struct Stamp(Vec<i64>);
44
45impl Stamp {
46    fn new(metadata: &std::fs::Metadata) -> Self {
47        let modified = metadata
48            .modified()
49            .ok()
50            .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
51            .map_or(-1, |duration| duration.as_nanos() as i64);
52        #[allow(unused_mut)]
53        let mut values = vec![metadata.len() as i64, modified];
54        #[cfg(unix)]
55        {
56            use std::os::unix::fs::MetadataExt;
57            values.extend([
58                metadata.ctime(),
59                metadata.ctime_nsec(),
60                metadata.ino() as i64,
61                i64::from(metadata.mode()),
62            ]);
63        }
64        Self(values)
65    }
66
67    /// Membership stamps also use change time: restoring a directory's mtime
68    /// must not allow changes inside a coarse timestamp window to go unnoticed.
69    fn membership_settled(&self) -> bool {
70        #[cfg(unix)]
71        let width = 6;
72        #[cfg(not(unix))]
73        let width = 2;
74        self.0.chunks_exact(width).all(|values| {
75            let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(values[1].max(0) as u64);
76            let settled = |time| {
77                SystemTime::now()
78                    .duration_since(time)
79                    .is_ok_and(|age| age > Duration::from_secs(2))
80            };
81            if values[1] < 0 || !settled(modified) {
82                return false;
83            }
84            #[cfg(unix)]
85            {
86                let changed = SystemTime::UNIX_EPOCH
87                    + Duration::new(values[2].max(0) as u64, values[3].max(0) as u32);
88                values[2] >= 0 && settled(changed)
89            }
90            #[cfg(not(unix))]
91            true
92        })
93    }
94
95    /// A file written within the timestamp resolution window could change again
96    /// without changing its stamp, so recent files are always re-read.
97    fn settled(&self) -> bool {
98        let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
99        self.0[1] >= 0
100            && SystemTime::now()
101                .duration_since(modified)
102                .is_ok_and(|age| age > Duration::from_secs(2))
103    }
104}
105
106/// Digests persisted between runs in the worktree's state, keyed by path and
107/// stamp, so a warm run reads only the files that changed. Stamps include the
108/// inode, so they belong to one worktree.
109fn digests_path(root: &Path) -> PathBuf {
110    paths::worktree_state(root).join("digests.json")
111}
112
113#[derive(PartialEq, Eq, Hash)]
114struct RuntimeKey {
115    command: String,
116    environment: BTreeMap<OsString, OsString>,
117}
118
119/// Workspace state shared by every fingerprint in one run. Inspection uses the
120/// initial candidate list; execution fingerprints refresh membership so files
121/// added or removed during the run cannot escape input verification.
122pub struct Snapshot {
123    pub(crate) files: BTreeSet<String>,
124    extra_candidates: BTreeSet<String>,
125    membership: Mutex<Option<Arc<Membership>>>,
126    membership_prefixes: Mutex<HashMap<String, Arc<Vec<PathBuf>>>>,
127    source_exclusions: Vec<Outputs>,
128    /// Every task's scratch directories, among `source_exclusions`, which no
129    /// input reads, `includeIgnored` ones included.
130    scratch: Vec<Outputs>,
131    cache_relative: Option<PathBuf>,
132    projects: ProjectGraph,
133    /// Each project by its root, to find the project owning a file.
134    roots: BTreeMap<String, String>,
135    generated_tasks: Vec<(Task, Outputs)>,
136    task_dependencies: BTreeMap<String, BTreeSet<String>>,
137    /// Each task's servers, by project and declared inputs: see [`servers`].
138    servers: BTreeMap<String, Vec<(String, Vec<Value>)>>,
139    source_ignore: Arc<SourceIgnore>,
140    canonical_root: PathBuf,
141    workspace_prefix: Option<PathBuf>,
142    patterns: Mutex<HashMap<(String, bool, bool), Arc<Pattern>>>,
143    digests: Mutex<HashMap<String, (Stamp, String)>>,
144    /// Files `includeIgnored` filesets read, whose digests are saved beside
145    /// those of `files`, which may not list them.
146    ignored_files: Mutex<BTreeSet<String>>,
147    /// Whether `digests` gained entries worth saving.
148    digests_changed: std::sync::atomic::AtomicBool,
149    /// Directories already checked not to be symlinks.
150    directories: Mutex<HashSet<PathBuf>>,
151    /// Runtime input results by command and environment, computed once per run like Nx.
152    runtime: Mutex<HashMap<RuntimeKey, Arc<Mutex<Option<Value>>>>>,
153    /// The parsed pnpm lockfile, replaced whenever its content changes.
154    lockfile: Mutex<Option<Arc<Installed>>>,
155    /// Each dependency task's output files, listed when a task first asks and
156    /// dropped once a task whose outputs overlap them is done.
157    dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
158    /// Counts drops, so a listing taken while a task finished is not kept.
159    outputs_written: std::sync::atomic::AtomicU64,
160}
161
162/// Membership depends on directory entries and Git/ignore listing policy, not
163/// on every source file's contents. Empty directories must also be watched.
164#[derive(Clone)]
165struct Membership {
166    files: Arc<BTreeSet<String>>,
167    source_ignore: Arc<SourceIgnore>,
168    stamps: BTreeMap<PathBuf, Option<Stamp>>,
169    directories: BTreeSet<PathBuf>,
170    directory_entries: BTreeMap<PathBuf, BTreeSet<(OsString, u8)>>,
171    root: PathBuf,
172    outputs: Vec<Outputs>,
173    output_anchors: BTreeMap<PathBuf, Vec<usize>>,
174    excluded_roots: BTreeSet<PathBuf>,
175    cache: Option<PathBuf>,
176    policies: BTreeSet<PathBuf>,
177    git_policies: BTreeSet<PathBuf>,
178    ignore_contents: BTreeMap<PathBuf, String>,
179    settled: bool,
180    /// When the refresh that produced this inventory began listing.
181    listed: Option<Instant>,
182}
183
184/// A candidate listing and the ignore policy captured with it. Keeping both
185/// alive prevents a concurrent refresh from changing dependency-output selection.
186#[derive(Clone)]
187struct Candidates {
188    files: Arc<BTreeSet<String>>,
189    source_ignore: Arc<SourceIgnore>,
190}
191
192/// The type [`Membership::entries`] records for a directory; a file is 0 and
193/// a symlink 2.
194const DIRECTORY: u8 = 1;
195/// Directories read at once while the inventory walks the workspace.
196const WALKERS: usize = 8;
197
198/// Whether `name` is an ignore file, whose rules change which files are sources.
199fn is_policy(name: &std::ffi::OsStr) -> bool {
200    matches!(name.to_str(), Some(".gitignore" | ".nxignore"))
201}
202
203impl Membership {
204    /// Inventory source directories and policy stamps before a new listing.
205    fn new(
206        root: &Path,
207        files: &BTreeSet<String>,
208        source_ignore: &Arc<SourceIgnore>,
209        outputs: &[Outputs],
210        cache: Option<&Path>,
211        git_policies: Option<&BTreeSet<PathBuf>>,
212    ) -> Result<Self> {
213        let mut output_anchors: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
214        for (index, outputs) in outputs.iter().enumerate() {
215            for anchor in outputs.anchors() {
216                output_anchors
217                    .entry(PathBuf::from(anchor))
218                    .or_default()
219                    .push(index);
220            }
221        }
222        let mut membership = Self {
223            files: Arc::new(BTreeSet::new()),
224            source_ignore: source_ignore.clone(),
225            stamps: BTreeMap::new(),
226            directories: BTreeSet::new(),
227            directory_entries: BTreeMap::new(),
228            root: root.to_owned(),
229            outputs: outputs.to_vec(),
230            output_anchors,
231            excluded_roots: outputs
232                .iter()
233                .flat_map(Outputs::complete_roots)
234                .map(PathBuf::from)
235                .collect(),
236            cache: cache.map(PathBuf::from),
237            policies: BTreeSet::new(),
238            git_policies: BTreeSet::new(),
239            ignore_contents: BTreeMap::new(),
240            settled: false,
241            listed: None,
242        };
243        // Finding Git's own policy files takes several Git processes, which
244        // run while the directories are read. They are stamped as soon as
245        // they are found: a config rewritten during the walk could name an
246        // excludes file the discovered set lacks, and a stamp taken after
247        // that rewrite would not show it.
248        let (git_policies, git_stamps) = std::thread::scope(|scope| {
249            let discovery = scope.spawn(|| {
250                let policies = git_policies
251                    .cloned()
252                    .unwrap_or_else(|| git_membership_policies(root));
253                let stamps = policies
254                    .iter()
255                    .map(|policy| Ok((policy.clone(), membership_stamp(policy)?)))
256                    .collect::<Result<BTreeMap<_, _>>>()?;
257                anyhow::Ok((policies, stamps))
258            });
259            membership.walk()?;
260            discovery.join().expect("git policy thread panicked")
261        })?;
262        // Git lists tracked files even beneath ignored directories. Watching
263        // only the ignore walker would miss deletion/rename of those files.
264        let mut beneath = Vec::new();
265        for path in files {
266            for parent in Path::new(path).ancestors().skip(1) {
267                let directory = root.join(parent);
268                // A watched directory's parent is watched too, so the
269                // nearest one watched ends the search.
270                if !membership.directories.insert(directory.clone()) {
271                    break;
272                }
273                membership
274                    .stamps
275                    .insert(directory.clone(), membership_stamp(&directory)?);
276                for name in [".gitignore", ".nxignore"] {
277                    let path = directory.join(name);
278                    // Creation of a new ignore file changes its directory stamp.
279                    // Existing rules can be edited without changing the directory.
280                    if membership_stamp(&path)?.is_some() {
281                        membership.policies.insert(path);
282                    }
283                }
284                beneath.push(directory);
285            }
286        }
287        for directory in root.ancestors() {
288            membership.policies.insert(directory.join(".gitignore"));
289            // Also detect a newly initialized repository or changed worktree pointer.
290            membership.policies.insert(directory.join(".git"));
291        }
292        membership.policies.extend(git_policies.iter().cloned());
293        membership.git_policies = git_policies;
294        for policy in &membership.policies {
295            let stamp = match git_stamps.get(policy) {
296                Some(stamp) => stamp.clone(),
297                None => membership_stamp(policy)?,
298            };
299            membership.stamps.insert(policy.clone(), stamp);
300        }
301        // Capture contents before listing, so a later policy change cannot be
302        // mistaken for the rules that produced that listing.
303        for policy in &membership.policies {
304            if policy.file_name().is_some_and(is_policy)
305                && let Some(content) = ignore_content(policy, &membership.stamps[policy])?
306            {
307                membership.ignore_contents.insert(policy.clone(), content);
308            }
309        }
310        // The walk did not read these: their names are captured as any
311        // later check reads them, now that every policy is known.
312        for directory in beneath {
313            if let Some(entries) = membership.entries(&directory)? {
314                membership.directory_entries.insert(directory, entries);
315            }
316        }
317        Ok(membership)
318    }
319
320    /// Stamp each directory from the root, then capture its names from the
321    /// same read that finds its subdirectories and ignore files. A change made
322    /// after the read changes the stamp, which later checks compare.
323    fn walk(&mut self) -> Result<()> {
324        // The directories waiting to be read, and how many are being read,
325        // whose subdirectories may still join them.
326        let queue = Mutex::new((vec![self.root.clone()], 0_usize));
327        let changed = Condvar::new();
328        let failed = AtomicBool::new(false);
329        let membership = &*self;
330        let read = |directory: &Path| -> Result<_> {
331            let stamp = membership_stamp(directory)?;
332            Ok((stamp, membership.listed(directory)?))
333        };
334        let walked: Vec<Result<Vec<_>>> = std::thread::scope(|scope| {
335            let walkers: Vec<_> = (0..WALKERS)
336                .map(|_| {
337                    scope.spawn(|| {
338                        let mut walked = Vec::new();
339                        loop {
340                            let directory = {
341                                let mut queue = queue.lock().unwrap();
342                                loop {
343                                    if failed.load(Ordering::Relaxed) {
344                                        return Ok(walked);
345                                    }
346                                    if let Some(directory) = queue.0.pop() {
347                                        queue.1 += 1;
348                                        break directory;
349                                    }
350                                    if queue.1 == 0 {
351                                        return Ok(walked);
352                                    }
353                                    queue = changed.wait(queue).unwrap();
354                                }
355                            };
356                            let result = read(&directory);
357                            let mut queue = queue.lock().unwrap();
358                            queue.1 -= 1;
359                            changed.notify_all();
360                            let (stamp, names) = match result {
361                                Ok(read) => read,
362                                Err(error) => {
363                                    failed.store(true, Ordering::Relaxed);
364                                    return Err(error);
365                                }
366                            };
367                            for (name, kind) in names.iter().flatten() {
368                                if *kind == DIRECTORY {
369                                    queue.0.push(directory.join(name));
370                                }
371                            }
372                            drop(queue);
373                            walked.push((directory, stamp, names));
374                        }
375                    })
376                })
377                .collect();
378            walkers
379                .into_iter()
380                .map(|walker| walker.join().expect("directory walker panicked"))
381                .collect()
382        });
383        for walked in walked {
384            for (directory, stamp, names) in walked? {
385                self.stamps.insert(directory.clone(), stamp);
386                if let Some(names) = names {
387                    for (name, _) in &names {
388                        // Creation of a new ignore file changes its directory stamp.
389                        // Existing rules can be edited without changing the directory.
390                        if is_policy(name) {
391                            self.policies.insert(directory.join(name));
392                        }
393                    }
394                    self.directory_entries.insert(directory.clone(), names);
395                }
396                self.directories.insert(directory);
397            }
398        }
399        Ok(())
400    }
401
402    /// Names and types that can affect source membership in this directory.
403    /// Output files may be skipped, but partially excluded output directories
404    /// must remain visible because they can contain source files or ignore rules.
405    fn entries(&self, directory: &Path) -> Result<Option<BTreeSet<(OsString, u8)>>> {
406        let Some(names) = self.listed(directory)? else {
407            return Ok(None);
408        };
409        for (name, kind) in &names {
410            let path = directory.join(name);
411            // A policy created between policy discovery and name capture must
412            // get its own stamp before its in-place edits can permit reuse.
413            if is_policy(name) && !self.policies.contains(&path) {
414                return Ok(None);
415            }
416            // An empty directory created after the inventory walk would not
417            // appear among source files. Never bless its parent signature until
418            // that directory is itself watched by the inventory.
419            if *kind == DIRECTORY && !self.directories.contains(&path) {
420                return Ok(None);
421            }
422        }
423        Ok(Some(names))
424    }
425
426    /// The names [`Self::entries`] keeps, before it checks that the inventory
427    /// already watches each directory and policy among them.
428    fn listed(&self, directory: &Path) -> Result<Option<BTreeSet<(OsString, u8)>>> {
429        let entries = match std::fs::read_dir(directory) {
430            Ok(entries) => entries,
431            Err(error)
432                if matches!(
433                    error.kind(),
434                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
435                ) =>
436            {
437                return Ok(None);
438            }
439            Err(error) => return Err(error.into()),
440        };
441        let relative_directory = directory.strip_prefix(&self.root).unwrap_or(directory);
442        let mut outputs = BTreeSet::new();
443        for ancestor in relative_directory.ancestors() {
444            if let Some(indices) = self.output_anchors.get(ancestor) {
445                outputs.extend(indices.iter().copied());
446            }
447        }
448        for (_, indices) in self
449            .output_anchors
450            .range(relative_directory.to_owned()..)
451            .take_while(|(anchor, _)| anchor.starts_with(relative_directory))
452        {
453            outputs.extend(indices.iter().copied());
454        }
455        let mut names = BTreeSet::new();
456        for entry in entries {
457            let entry = entry?;
458            let name = entry.file_name();
459            let path = entry.path();
460            let relative = path.strip_prefix(&self.root).unwrap_or(&path);
461            if matches!(name.to_str(), Some(".git" | ".qk" | "node_modules"))
462                || self
463                    .cache
464                    .as_ref()
465                    .is_some_and(|cache| relative.starts_with(cache))
466                || relative
467                    .ancestors()
468                    .any(|root| self.excluded_roots.contains(root))
469            {
470                continue;
471            }
472            let kind = entry.file_type()?;
473            if !kind.is_dir()
474                && !is_policy(&name)
475                && relative.to_str().is_some_and(|path| {
476                    outputs
477                        .iter()
478                        .any(|index| self.outputs[*index].matches(path))
479                })
480            {
481                continue;
482            }
483            names.insert((
484                name,
485                if kind.is_dir() {
486                    DIRECTORY
487                } else if kind.is_symlink() {
488                    2
489                } else {
490                    0
491                },
492            ));
493        }
494        Ok(Some(names))
495    }
496
497    /// Check source-relevant directory membership and every listing policy.
498    /// Recent directory stamps require fresh entry names; recent ignore files
499    /// require matching contents, while other listing policies must settle.
500    fn state(&self, prefixes: Option<&[PathBuf]>) -> Result<(bool, bool)> {
501        let mut settled = true;
502        let selected = prefixes.map(|prefixes| {
503            let mut selected = BTreeSet::new();
504            for prefix in prefixes {
505                for ancestor in prefix.ancestors() {
506                    if let Some(directory) = self.directories.get(ancestor) {
507                        selected.insert(directory);
508                    }
509                }
510                selected.extend(
511                    self.directories
512                        .range(prefix.clone()..)
513                        .take_while(|directory| directory.starts_with(prefix)),
514                );
515            }
516            selected
517        });
518        let paths: Box<dyn Iterator<Item = (&PathBuf, bool)>> = match selected {
519            Some(selected) => Box::new(
520                selected
521                    .into_iter()
522                    .map(|path| (path, true))
523                    .chain(self.policies.iter().map(|path| (path, false))),
524            ),
525            None => Box::new(
526                self.stamps
527                    .keys()
528                    .map(|path| (path, self.directories.contains(path))),
529            ),
530        };
531        for (path, directory) in paths {
532            let before = &self.stamps[path];
533            let after = membership_stamp(path)?;
534            if directory {
535                if after.is_some() && !self.directory_entries.contains_key(path) {
536                    return Ok((false, false));
537                }
538                // Output publication changes its parent's timestamp too. Check
539                // source-relevant names rather than relisting the whole workspace.
540                // Recent directories are always read, even with an equal stamp:
541                // coarse timestamps can conceal an addition or removal.
542                if *before != after
543                    || after
544                        .as_ref()
545                        .is_some_and(|stamp| !stamp.membership_settled())
546                {
547                    let entries = self.entries(path)?;
548                    if entries.as_ref() != self.directory_entries.get(path)
549                        || membership_stamp(path)? != after
550                    {
551                        return Ok((false, false));
552                    }
553                }
554            } else {
555                let recent = after
556                    .as_ref()
557                    .is_some_and(|stamp| !stamp.membership_settled());
558                if (*before != after || recent)
559                    && let Some(content) = self.ignore_contents.get(path)
560                {
561                    if ignore_content(path, &after)?.as_ref() != Some(content) {
562                        return Ok((false, false));
563                    }
564                    continue;
565                }
566                if *before != after {
567                    return Ok((false, false));
568                }
569                settled &= !recent;
570            }
571        }
572        Ok((true, settled))
573    }
574
575    /// Retain a matched listing and policy for use after releasing the lock.
576    fn candidates(&self) -> Candidates {
577        Candidates {
578            files: self.files.clone(),
579            source_ignore: self.source_ignore.clone(),
580        }
581    }
582}
583
584/// Find Git index, exclusion and config files that can change source visibility,
585/// including absent policy files whose later creation must invalidate reuse.
586fn git_membership_policies(root: &Path) -> BTreeSet<PathBuf> {
587    let mut policies = BTreeSet::new();
588    if let Ok(output) = paths::git(
589        root,
590        &[
591            "rev-parse",
592            "--path-format=absolute",
593            "--git-path",
594            "index",
595            "--git-path",
596            "info/exclude",
597            "--git-path",
598            "config",
599            "--git-path",
600            "config.worktree",
601        ],
602    ) && output.status.success()
603        && let Ok(paths) = String::from_utf8(output.stdout)
604    {
605        policies.extend(paths.lines().map(PathBuf::from));
606    }
607    // Config can include other files. Track every origin used by Git, as
608    // well as currently missing standard config and excludes files.
609    if let Ok(output) = paths::git(root, &["config", "--null", "--show-origin", "--list"])
610        && output.status.success()
611    {
612        // NUL mode emits raw origins, without Git's C-style path quoting,
613        // and separates each origin from its key/newline/value record. Values
614        // may themselves contain newlines (including include/excludes paths).
615        let output = String::from_utf8_lossy(&output.stdout);
616        let mut fields = output.split_terminator('\0');
617        while let (Some(origin), Some(entry)) = (fields.next(), fields.next()) {
618            if let Some(path) = origin.strip_prefix("file:") {
619                let origin = root.join(path);
620                if let Some((name, value)) = entry.split_once('\n')
621                    && name.starts_with("include")
622                    && name.ends_with(".path")
623                {
624                    let included = if let Some(relative) = value.strip_prefix("~/") {
625                        std::env::var_os("HOME")
626                            .map(PathBuf::from)
627                            .unwrap_or_default()
628                            .join(relative)
629                    } else {
630                        origin.parent().unwrap_or(root).join(value)
631                    };
632                    // Git omits missing includes from the origin listing, but
633                    // creating one later can change core.excludesfile.
634                    policies.insert(included);
635                }
636                policies.insert(origin);
637            }
638        }
639    }
640    if let Ok(output) = paths::git(
641        root,
642        &["config", "--null", "--path", "--get", "core.excludesfile"],
643    ) && output.status.success()
644        && let Ok(path) = String::from_utf8(output.stdout)
645    {
646        policies.insert(root.join(path.strip_suffix('\0').unwrap_or(&path)));
647    }
648    for variable in ["GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL"] {
649        if let Some(path) = std::env::var_os(variable) {
650            policies.insert(PathBuf::from(path));
651        }
652    }
653    policies.insert(PathBuf::from("/etc/gitconfig"));
654    if let Some(home) = std::env::var_os("HOME") {
655        let home = PathBuf::from(home);
656        policies.insert(home.join(".gitconfig"));
657        let config = std::env::var_os("XDG_CONFIG_HOME")
658            .map(PathBuf::from)
659            .unwrap_or_else(|| home.join(".config"));
660        policies.insert(config.join("git/config"));
661        policies.insert(config.join("git/ignore"));
662    }
663    policies
664}
665
666/// Read a regular ignore policy only when the captured metadata remains stable.
667/// A digest read after a listing must never replace the pre-listing digest.
668fn ignore_content(path: &Path, expected: &Option<Stamp>) -> Result<Option<String>> {
669    if expected.is_none() || membership_stamp(path)? != *expected {
670        return Ok(None);
671    }
672    match std::fs::symlink_metadata(path) {
673        Ok(metadata) if metadata.is_file() => {}
674        Ok(_) => return Ok(None),
675        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
676        Err(error) => return Err(error.into()),
677    }
678    let content = match std::fs::read(path) {
679        Ok(content) => content,
680        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
681        Err(error) => return Err(error.into()),
682    };
683    Ok((membership_stamp(path)? == *expected).then(|| blake3::hash(&content).to_hex().to_string()))
684}
685
686/// Stamp a directory or listing policy, including a symlink's resolved target.
687fn membership_stamp(path: &Path) -> Result<Option<Stamp>> {
688    match std::fs::symlink_metadata(path) {
689        Ok(metadata) => {
690            let mut stamp = Stamp::new(&metadata);
691            if metadata.is_symlink() {
692                // Follow the current chain on every check. In-place target
693                // edits or retargeted intermediate links can change policy.
694                match std::fs::metadata(path) {
695                    Ok(target) => stamp.0.extend(Stamp::new(&target).0),
696                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => stamp.0[1] = -1,
697                    Err(error) => return Err(error.into()),
698                }
699            }
700            Ok(Some(stamp))
701        }
702        Err(error)
703            if matches!(
704                error.kind(),
705                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
706            ) =>
707        {
708            Ok(None)
709        }
710        Err(error) => Err(error).with_context(|| format!("cannot inspect {}", path.display())),
711    }
712}
713
714/// One dependency task's output files, and the value of each one a pattern
715/// has selected so far.
716struct DependencyOutputs {
717    anchors: Vec<String>,
718    files: Vec<String>,
719    values: Mutex<HashMap<String, Value>>,
720}
721
722/// One revision of `pnpm-lock.yaml`, with digests of what it installs computed
723/// once and shared by every task that asks.
724struct Installed {
725    content: String,
726    /// `None` when the file cannot be read as a pnpm v9 lockfile.
727    lockfile: Option<Lockfile>,
728    digests: Mutex<HashMap<String, String>>,
729}
730
731impl Installed {
732    fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
733        if let Some(digest) = self.digests.lock().unwrap().get(key) {
734            return digest.clone();
735        }
736        let mut hasher = blake3::Hasher::new();
737        for fingerprint in fingerprints() {
738            hasher.update(fingerprint.as_bytes());
739            hasher.update(b"\n");
740        }
741        let digest = hasher.finalize().to_hex().to_string();
742        self.digests
743            .lock()
744            .unwrap()
745            .insert(key.to_owned(), digest.clone());
746        digest
747    }
748}
749
750impl Snapshot {
751    pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
752        // Every early task needs these, so loading saved digests and parsing
753        // the lockfile overlap listing files.
754        let canonical_root = workspace.root.canonicalize()?;
755        let (files, lockfile, (digests, workspace_prefix)) = std::thread::scope(|scope| {
756            let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
757            let digests = scope.spawn(|| {
758                let digests = load_digests(&workspace.root);
759                let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
760                    .and_then(|root| root.canonicalize().ok())
761                    .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
762                (digests, workspace_prefix)
763            });
764            let files = source_files(&workspace.root);
765            (
766                files,
767                lockfile.join().expect("lockfile thread panicked"),
768                digests.join().expect("digest thread panicked"),
769            )
770        });
771        let mut files = files?;
772        let cache_relative = cache_path
773            .strip_prefix(&workspace.root)
774            .ok()
775            .map(PathBuf::from);
776        // Keep the patterns, not only the artifacts present at startup: new
777        // outputs and warm scratch files must be excluded on every refresh.
778        let mut source_exclusions: Vec<_> = graph
779            .tasks
780            .values()
781            .filter_map(|task| Outputs::new(workspace, task).ok())
782            .filter(Outputs::is_explicit)
783            .collect();
784        for task in graph.tasks.values() {
785            let Ok(entries) = crate::warm::config(workspace, task) else {
786                continue;
787            };
788            if let Ok(paths) = Outputs::from_paths(&crate::warm::kept_paths(&entries)) {
789                source_exclusions.push(paths);
790            }
791        }
792        // Scratch directories, which each run empties: what is in them is no
793        // input, before or after the task runs.
794        let mut scratch_paths = Vec::new();
795        for task in graph.tasks.values() {
796            let scratch: Vec<String> = crate::scratch::directories(workspace, task)
797                .unwrap_or_default()
798                .iter()
799                .filter_map(|directory| {
800                    let relative = directory.strip_prefix(&workspace.root).ok()?.to_str()?;
801                    let named = crate::glob::escape(&relative.replace('\\', "/"));
802                    Some([named.clone(), format!("{named}/**")])
803                })
804                .flatten()
805                .collect();
806            if !scratch.is_empty()
807                && let Ok(paths) = Outputs::from_paths(&scratch)
808            {
809                source_exclusions.push(paths.clone());
810                scratch_paths.push(paths);
811            }
812        }
813        exclude_artifacts(&mut files, &source_exclusions, cache_relative.as_deref());
814        Ok(Self {
815            files,
816            extra_candidates: BTreeSet::new(),
817            membership: Mutex::default(),
818            membership_prefixes: Mutex::default(),
819            source_exclusions,
820            scratch: scratch_paths,
821            cache_relative,
822            projects: ProjectGraph::build(workspace)?,
823            roots: workspace
824                .projects
825                .values()
826                .map(|project| (project.root.clone(), project.name.clone()))
827                .collect(),
828            source_ignore: Arc::new(SourceIgnore::new(&workspace.root)?),
829            generated_tasks: graph
830                .tasks
831                .values()
832                .filter_map(|task| {
833                    let outputs = Outputs::new(workspace, task).ok()?;
834                    outputs.is_explicit().then(|| (task.clone(), outputs))
835                })
836                .collect(),
837            task_dependencies: graph
838                .tasks
839                .iter()
840                .map(|(id, task)| (id.clone(), task.dependencies.clone()))
841                .collect(),
842            servers: graph
843                .tasks
844                .keys()
845                .map(|id| (id.clone(), servers(graph, id)))
846                .collect(),
847            canonical_root,
848            workspace_prefix,
849            patterns: Mutex::default(),
850            digests: Mutex::new(digests),
851            digests_changed: Default::default(),
852            ignored_files: Mutex::default(),
853            directories: Mutex::default(),
854            runtime: Mutex::default(),
855            lockfile: Mutex::new(lockfile),
856            dependency_outputs: Mutex::default(),
857            outputs_written: Default::default(),
858        })
859    }
860
861    /// Restrict directory checks to the positive fileset prefixes a task reads.
862    fn membership_prefixes(&self, workspace: &Workspace, task: &Task) -> Result<Arc<Vec<PathBuf>>> {
863        let mut prefixes = self.membership_prefixes.lock().unwrap();
864        if let Some(prefixes) = prefixes.get(&task.id) {
865            return Ok(prefixes.clone());
866        }
867        let empty = BTreeSet::new();
868        let cancelled = AtomicBool::new(false);
869        let mut resolver = Resolver::new(self, workspace, None, &cancelled, &empty);
870        resolver.task_inputs(task)?;
871        let paths = Arc::new(
872            resolver
873                .scopes
874                .values()
875                .flat_map(|scope| &scope.patterns)
876                .filter(|pattern| !pattern.excluded)
877                .map(|pattern| &pattern.prefix)
878                .chain(resolver.json_globs.iter().map(|glob| &glob.prefix))
879                .map(|prefix| workspace.root.join(prefix))
880                .collect(),
881        );
882        prefixes.insert(task.id.clone(), Arc::clone(&paths));
883        Ok(paths)
884    }
885
886    /// The directories a task's `includeIgnored` filesets read from, as
887    /// workspace-relative paths: each positive glob's leading literal
888    /// segments.
889    pub(crate) fn include_ignored_roots(
890        &self,
891        workspace: &Workspace,
892        task: &Task,
893    ) -> Result<Vec<String>> {
894        let empty = BTreeSet::new();
895        let cancelled = AtomicBool::new(false);
896        let mut resolver = Resolver::new(self, workspace, None, &cancelled, &empty);
897        resolver.task_inputs(task)?;
898        Ok(resolver
899            .ignored
900            .values()
901            .flatten()
902            .filter(|glob| !glob.starts_with('!'))
903            .map(|glob| crate::ignored::partition(glob.trim_end_matches('/')).0)
904            .collect())
905    }
906
907    /// Reuse membership while source directories and listing policy are unchanged.
908    /// Content changes are checked separately by the file digest cache.
909    #[cfg(test)]
910    fn current_files(&self, root: &Path) -> Result<Arc<BTreeSet<String>>> {
911        Ok(self.current_files_for(root, None, "test")?.files)
912    }
913
914    /// Refresh candidates as needed, returning their policy in the same snapshot.
915    fn current_files_for(
916        &self,
917        root: &Path,
918        prefixes: Option<&[PathBuf]>,
919        task: &str,
920    ) -> Result<Candidates> {
921        let requested = Instant::now();
922        loop {
923            let previous = self.membership.lock().unwrap().clone();
924            // Immutable entries let independent fingerprints validate their
925            // directory stamps concurrently. Only a refresh needs serialization.
926            let (unchanged, settled) = match &previous {
927                Some(entry) => entry.state(prefixes)?,
928                None => (false, false),
929            };
930            if settled && previous.as_ref().unwrap().settled {
931                return Ok(previous.as_ref().unwrap().candidates());
932            }
933            // A refresh holds this lock while it lists the workspace, so time
934            // spent here is time waiting for another task's listing.
935            let waiting = crate::profile::span(task, "inventory_wait");
936            let mut cached = self.membership.lock().unwrap();
937            drop(waiting);
938            // A listing that began after this call reflects every change made
939            // before it, so it is as current as one made now. Tasks queued
940            // behind a refresh share the next one instead of each listing the
941            // workspace in turn.
942            if let Some(entry) = &*cached
943                && entry.listed.is_some_and(|listed| listed >= requested)
944            {
945                return Ok(entry.candidates());
946            }
947            let current = match (&previous, &*cached) {
948                (Some(previous), Some(current)) => Arc::ptr_eq(previous, current),
949                (None, None) => true,
950                _ => false,
951            };
952            if !current {
953                // Another fingerprint refreshed while this one validated.
954                // Recheck that newer inventory before making any replacement.
955                continue;
956            }
957            drop(previous);
958            let _listing = crate::profile::span(task, "inventory_list");
959            let listed = Instant::now();
960            // Recent listing policies may conceal edits on coarse filesystems.
961            // Re-list sources while they settle, then rebuild the directory inventory
962            // once before enabling reuse (including any newly created empty dirs).
963            // Recent directories are checked by freshly reading their child names.
964            let rebuilt = !unchanged || settled;
965            let mut membership = if !rebuilt {
966                Arc::unwrap_or_clone(cached.take().unwrap())
967            } else {
968                // Capture directories before listing files, then compare afterward.
969                // An addition during listing must not hide behind a newer stamp.
970                Membership::new(
971                    root,
972                    cached.as_ref().map_or(&self.files, |entry| &entry.files),
973                    &self.source_ignore,
974                    &self.source_exclusions,
975                    self.cache_relative.as_deref(),
976                    cached
977                        .as_ref()
978                        .filter(|entry| unchanged && entry.settled)
979                        .map(|entry| &entry.git_policies),
980                )?
981            };
982            let mut files = source_files(root)?;
983            exclude_artifacts(
984                &mut files,
985                &self.source_exclusions,
986                self.cache_relative.as_deref(),
987            );
988            let ignore = SourceIgnore::new(root)?;
989            files.extend(
990                self.extra_candidates
991                    .iter()
992                    .filter(|path| !ignore.matches(path))
993                    .cloned(),
994            );
995            membership.source_ignore = Arc::new(ignore);
996            membership.files = Arc::new(files);
997            let candidates = membership.candidates();
998            // An unstable inventory or recent policy never enables the fast path.
999            // Keep candidates so a rebuild also watches tracked, ignored parents.
1000            let covered = candidates.files.iter().all(|path| {
1001                Path::new(path)
1002                    .ancestors()
1003                    .skip(1)
1004                    .all(|parent| membership.stamps.contains_key(&root.join(parent)))
1005            });
1006            membership.settled = rebuilt && covered && membership.state(None)?.1;
1007            membership.listed = Some(listed);
1008            *cached = Some(Arc::new(membership));
1009            return Ok(candidates);
1010        }
1011    }
1012
1013    /// Saves the digests of files that still exist, for the next run.
1014    pub fn save_digests(&self, root: &Path) {
1015        if !self
1016            .digests_changed
1017            .load(std::sync::atomic::Ordering::Relaxed)
1018        {
1019            return;
1020        }
1021        let digests = self.digests.lock().unwrap();
1022        let ignored = self.ignored_files.lock().unwrap();
1023        let entries: BTreeMap<&String, (&Stamp, &String)> = digests
1024            .iter()
1025            .filter(|(path, _)| self.files.contains(*path) || ignored.contains(*path))
1026            .map(|(path, (stamp, digest))| (path, (stamp, digest)))
1027            .collect();
1028        let path = digests_path(root);
1029        let saved = (|| -> Result<()> {
1030            let directory = path.parent().context("digests have a directory")?;
1031            std::fs::create_dir_all(directory)?;
1032            let mut file = tempfile::NamedTempFile::new_in(directory)?;
1033            {
1034                let mut writer = std::io::BufWriter::new(file.as_file_mut());
1035                serde_json::to_writer(&mut writer, &json!({"version": 1, "entries": entries}))?;
1036                writer.flush()?;
1037            }
1038            file.persist(&path)?;
1039            Ok(())
1040        })();
1041        if let Err(error) = saved {
1042            qk_executor::status!("qk: could not save file digests: {error:#}");
1043        }
1044    }
1045
1046    /// `paths::safe_parents`, checking each directory once per run.
1047    fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
1048        paths::validate_path(path)?;
1049        let mut current = root.to_owned();
1050        for part in Path::new(path)
1051            .parent()
1052            .into_iter()
1053            .flat_map(Path::components)
1054        {
1055            current.push(part);
1056            if self.directories.lock().unwrap().contains(&current) {
1057                continue;
1058            }
1059            match std::fs::symlink_metadata(&current) {
1060                Ok(metadata) if metadata.file_type().is_symlink() => {
1061                    bail!("input parent is a symlink: {}", current.display())
1062                }
1063                Ok(metadata) if !metadata.is_dir() => {
1064                    bail!("input parent is not a directory: {}", current.display())
1065                }
1066                Ok(_) => {
1067                    self.directories.lock().unwrap().insert(current.clone());
1068                }
1069                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1070                Err(error) => return Err(error.into()),
1071            }
1072        }
1073        Ok(())
1074    }
1075
1076    fn installed(&self, root: &Path, task: &str) -> Result<Option<Arc<Installed>>> {
1077        let absolute = root.join("pnpm-lock.yaml");
1078        let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
1079            return Ok(None);
1080        };
1081        if !metadata.is_file() {
1082            return Ok(None);
1083        }
1084        let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
1085        let mut current = self.lockfile.lock().unwrap();
1086        if let Some(installed) = &*current
1087            && installed.content == content
1088        {
1089            return Ok(Some(installed.clone()));
1090        }
1091        let parsing = crate::profile::span(task, "lockfile_parse");
1092        let parsed = parse_lockfile(&absolute);
1093        drop(parsing);
1094        let Some(installed) = parsed else {
1095            return Ok(None);
1096        };
1097        *current = Some(installed.clone());
1098        Ok(Some(installed))
1099    }
1100
1101    /// Adds paths to the candidate files, such as files deleted since a base
1102    /// revision.
1103    pub fn with_candidates(mut self, paths: &[String]) -> Self {
1104        self.files.extend(
1105            paths
1106                .iter()
1107                .filter(|path| !self.source_ignore.matches(path))
1108                .cloned(),
1109        );
1110        self.extra_candidates.extend(paths.iter().cloned());
1111        self
1112    }
1113
1114    fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
1115        self.compiled(pattern, negated, false)
1116    }
1117
1118    /// A fileset's pattern, see [`Pattern::fileset`].
1119    fn fileset_pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
1120        self.compiled(pattern, negated, true)
1121    }
1122
1123    fn compiled(&self, pattern: &str, negated: bool, fileset: bool) -> Result<Arc<Pattern>> {
1124        let key = (pattern.to_owned(), negated, fileset);
1125        if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
1126            return Ok(pattern.clone());
1127        }
1128        let compiled = Arc::new(if fileset {
1129            Pattern::fileset(pattern, negated)?
1130        } else {
1131            Pattern::new(pattern, negated)?
1132        });
1133        self.patterns.lock().unwrap().insert(key, compiled.clone());
1134        Ok(compiled)
1135    }
1136
1137    fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
1138        let stamp = Stamp::new(metadata);
1139        if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
1140            && *known == stamp
1141        {
1142            return Ok(digest.clone());
1143        }
1144        let digest = digest_file(absolute)?;
1145        // Re-check after reading so a concurrent write is never recorded as settled.
1146        let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
1147        if after == stamp && stamp.settled() {
1148            self.digests
1149                .lock()
1150                .unwrap()
1151                .insert(path.to_owned(), (stamp, digest.clone()));
1152            self.digests_changed
1153                .store(true, std::sync::atomic::Ordering::Relaxed);
1154        }
1155        Ok(digest)
1156    }
1157
1158    /// `task`'s output files that are not directories, as listed since the
1159    /// last task that could have written them was done.
1160    fn dependency_outputs(
1161        &self,
1162        workspace: &Workspace,
1163        task: &Task,
1164    ) -> Result<Arc<DependencyOutputs>> {
1165        if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
1166            return Ok(outputs.clone());
1167        }
1168        let written = self
1169            .outputs_written
1170            .load(std::sync::atomic::Ordering::SeqCst);
1171        let outputs = Arc::new(self.fresh_dependency_outputs(workspace, task)?);
1172        let mut listed = self.dependency_outputs.lock().unwrap();
1173        if self
1174            .outputs_written
1175            .load(std::sync::atomic::Ordering::SeqCst)
1176            != written
1177        {
1178            return Ok(outputs);
1179        }
1180        Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
1181    }
1182
1183    /// Re-list artifacts for verification, without reusing earlier content values.
1184    fn fresh_dependency_outputs(
1185        &self,
1186        workspace: &Workspace,
1187        task: &Task,
1188    ) -> Result<DependencyOutputs> {
1189        let declared = Outputs::new(workspace, task)?;
1190        let mut files = Vec::new();
1191        for entry in declared.entries(&workspace.root)? {
1192            let (path, metadata) = entry?;
1193            if !metadata.is_dir() {
1194                files.push(path);
1195            }
1196        }
1197        Ok(DependencyOutputs {
1198            anchors: declared.anchors().map(str::to_owned).collect(),
1199            files,
1200            values: Mutex::default(),
1201        })
1202    }
1203
1204    /// Drops the listings `task` may have changed, once it is done. Without
1205    /// its outputs, every listing goes.
1206    pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
1207        let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
1208            .ok()
1209            .filter(Outputs::is_explicit)
1210            .map(|outputs| outputs.anchors().map(str::to_owned).collect());
1211        let overlap = |a: &str, b: &str| {
1212            a == b
1213                || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1214                || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1215        };
1216        let mut listed = self.dependency_outputs.lock().unwrap();
1217        self.outputs_written
1218            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1219        listed.retain(|_, outputs| {
1220            anchors.as_ref().is_some_and(|written| {
1221                !written
1222                    .iter()
1223                    .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
1224            })
1225        });
1226    }
1227
1228    /// A file an `includeIgnored` fileset read, by the content it points to
1229    /// wherever that is, as Nx reads it; `None` when it is gone.
1230    fn ignored_value(&self, root: &Path, path: &str) -> Result<Option<Value>> {
1231        let absolute = root.join(path);
1232        let metadata = match std::fs::metadata(&absolute) {
1233            Ok(metadata) if metadata.is_file() => metadata,
1234            Ok(_) => return Ok(None),
1235            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1236            Err(error) => return Err(error.into()),
1237        };
1238        let mode = crate::store::mode(&metadata) & 0o111 != 0;
1239        self.ignored_files.lock().unwrap().insert(path.to_owned());
1240        Ok(Some(
1241            json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}),
1242        ))
1243    }
1244
1245    /// A file a recording read, keyed as an input file is.
1246    pub(crate) fn recorded_value(&self, root: &Path, path: &str) -> Result<Value> {
1247        self.file_value(root, path)
1248    }
1249
1250    fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
1251        self.file_value_with_links(root, path, &mut BTreeSet::new())
1252    }
1253
1254    fn file_value_with_links(
1255        &self,
1256        root: &Path,
1257        path: &str,
1258        visiting: &mut BTreeSet<PathBuf>,
1259    ) -> Result<Value> {
1260        self.safe_parents(root, path)?;
1261        let absolute = root.join(path);
1262        let metadata = std::fs::symlink_metadata(&absolute)?;
1263        if metadata.file_type().is_symlink() {
1264            let target = std::fs::read_link(&absolute)?;
1265            // A dangling link is keyed as such, so the key changes once it resolves.
1266            let Ok(resolved) = absolute.canonicalize() else {
1267                return Ok(json!({"link":target, "dangling":true}));
1268            };
1269            let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
1270                bail!("input symlink resolves outside the workspace: {path}");
1271            };
1272            if resolved.is_file() {
1273                let metadata = std::fs::metadata(&resolved)?;
1274                let key = paths::relative(Path::new(""), relative)?;
1275                return Ok(
1276                    json!({"link":target, "content":self.digest(&key, &resolved, &metadata)?,
1277                    "mode":crate::store::mode(&metadata) & 0o111 != 0}),
1278                );
1279            }
1280            let relative = paths::relative(Path::new(""), relative)?;
1281            if relative.is_empty() {
1282                bail!("input symlink resolves to the workspace root: {path}");
1283            }
1284            if !visiting.insert(resolved.clone()) {
1285                bail!("directory input symlink cycle: {path}");
1286            }
1287            let mut contents = BTreeMap::new();
1288            // Selecting the link explicitly reads its target, even when source
1289            // discovery ignores that target or another link is nested inside it.
1290            for entry in walkdir::WalkDir::new(&resolved).follow_links(false) {
1291                let entry = entry?;
1292                if entry.file_type().is_dir() {
1293                    continue;
1294                }
1295                let file = paths::relative(&self.canonical_root, entry.path())?;
1296                let value = self.file_value_with_links(&self.canonical_root, &file, visiting)?;
1297                contents.insert(
1298                    file.strip_prefix(&relative).unwrap_or(&file).to_owned(),
1299                    value,
1300                );
1301            }
1302            visiting.remove(&resolved);
1303            return Ok(json!({"link":target, "directory":contents}));
1304        }
1305        if !metadata.is_file() {
1306            bail!("input is not a regular file: {path}");
1307        }
1308        // Only the executable bit, which is all git records: other permission
1309        // bits follow the checkout's umask and would keep machines apart.
1310        let mode = crate::store::mode(&metadata) & 0o111 != 0;
1311        Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
1312    }
1313}
1314
1315/// Reads and parses a pnpm lockfile, `None` when there is none. One qk cannot
1316/// read is reported, and kept as unreadable so tasks key its whole content.
1317fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
1318    let text = std::fs::read_to_string(path).ok()?;
1319    let lockfile = match Lockfile::parse(&text) {
1320        Ok(lockfile) => Some(lockfile),
1321        Err(error) => {
1322            qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
1323            None
1324        }
1325    };
1326    Some(Arc::new(Installed {
1327        content: blake3::hash(text.as_bytes()).to_hex().to_string(),
1328        lockfile,
1329        digests: Mutex::default(),
1330    }))
1331}
1332
1333fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
1334    #[derive(serde::Deserialize)]
1335    struct Saved {
1336        version: u32,
1337        entries: HashMap<String, (Stamp, String)>,
1338    }
1339    std::fs::read(digests_path(root))
1340        .ok()
1341        .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
1342        .filter(|saved| saved.version == 1)
1343        .map(|saved| saved.entries)
1344        .unwrap_or_default()
1345}
1346
1347/// Apply the same source policy to startup and refreshed file membership.
1348/// Remove declared artifacts and cache storage from a refreshed source listing.
1349fn exclude_artifacts(files: &mut BTreeSet<String>, outputs: &[Outputs], cache: Option<&Path>) {
1350    let mut generated = BTreeSet::new();
1351    for outputs in outputs {
1352        for anchor in outputs.anchors() {
1353            generated.extend(
1354                under(files, anchor)
1355                    .filter(|path| outputs.matches(path))
1356                    .cloned(),
1357            );
1358        }
1359    }
1360    files.retain(|path| {
1361        !generated.contains(path) && cache.is_none_or(|cache| !Path::new(path).starts_with(cache))
1362    });
1363}
1364
1365/// Files equal to `prefix` or below it, using the sorted order of the set.
1366impl Snapshot {
1367    /// The project whose root most specifically contains `path`, as Nx assigns
1368    /// files to projects.
1369    fn owner(&self, path: &str) -> Option<&str> {
1370        let mut path = path;
1371        loop {
1372            if let Some(project) = self.roots.get(path) {
1373                return Some(project);
1374            }
1375            match path.rfind('/') {
1376                Some(slash) => path = &path[..slash],
1377                None if path != "." => path = ".",
1378                None => return None,
1379            }
1380        }
1381    }
1382}
1383
1384fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
1385    files
1386        .range::<str, _>((
1387            std::ops::Bound::Included(prefix),
1388            std::ops::Bound::Unbounded,
1389        ))
1390        .take_while(move |path| path.starts_with(prefix))
1391        .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
1392}
1393
1394/// Compiled root ignore rules used by input discovery and project watching.
1395pub struct SourceIgnore(ignore::gitignore::Gitignore);
1396
1397impl SourceIgnore {
1398    /// Root ignore rules shared by caching and watch, including tracked files.
1399    pub fn new(root: &Path) -> Result<Self> {
1400        let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
1401        for name in [".gitignore", ".nxignore"] {
1402            let path = root.join(name);
1403            if path.is_file()
1404                && let Some(error) = builder.add(path)
1405            {
1406                return Err(error.into());
1407            }
1408        }
1409        Ok(Self(builder.build()?))
1410    }
1411
1412    fn matches_directory(&self, path: &str) -> bool {
1413        self.matches(path) || self.0.matched(path, true).is_ignore()
1414    }
1415
1416    /// Whether a workspace-relative file or its parent is ignored.
1417    pub fn matches(&self, path: &str) -> bool {
1418        // A file whitelist cannot reinclude it beneath an excluded directory.
1419        Path::new(path)
1420            .ancestors()
1421            .skip(1)
1422            .take_while(|parent| !parent.as_os_str().is_empty())
1423            .any(|parent| self.0.matched(parent, true).is_ignore())
1424            || self.0.matched(path, false).is_ignore()
1425    }
1426}
1427
1428/// Source files visible to Nx, with runner state and root ignore rules removed.
1429pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
1430    let lines =
1431        |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
1432            let output = output.ok().filter(|output| output.status.success())?;
1433            Some(
1434                output
1435                    .stdout
1436                    .split(|byte| *byte == 0)
1437                    .filter(|path| !path.is_empty())
1438                    .map(|path| {
1439                        String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
1440                    })
1441                    .collect(),
1442            )
1443        };
1444    // Tracked files deleted from the working tree are listed as cached; git
1445    // names them in a second listing, which is cheaper than checking each file.
1446    // Skip-worktree entries are listed whether or not they are on disk and never
1447    // as deleted, so those few are checked; `-v` tags them `S`.
1448    let (listed, deleted) = if root.join(".nxignore").is_file() {
1449        // Nx negations can reinclude Git-ignored files, so this policy needs
1450        // the walker regardless of what Git lists. Avoid launching Git here.
1451        (None, None)
1452    } else {
1453        std::thread::scope(|scope| {
1454            let deleted =
1455                scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
1456            let listed = paths::git(
1457                root,
1458                &[
1459                    "ls-files",
1460                    "-v",
1461                    "--cached",
1462                    "--others",
1463                    "--exclude-standard",
1464                    "-z",
1465                    "--",
1466                    ".",
1467                ],
1468            );
1469            (
1470                lines(listed),
1471                lines(deleted.join().expect("git listing thread panicked")),
1472            )
1473        })
1474    };
1475    let candidates = match (listed, deleted) {
1476        (Some(listed), Some(deleted)) => {
1477            let deleted = deleted?;
1478            let mut files = BTreeSet::new();
1479            for entry in listed? {
1480                let Some((tag, path)) = entry.split_once(' ') else {
1481                    bail!("unexpected git ls-files entry {entry:?}");
1482                };
1483                let absent =
1484                    matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
1485                if !absent && !deleted.contains(path) {
1486                    files.insert(path.to_owned());
1487                }
1488            }
1489            files
1490        }
1491        _ => {
1492            let mut files = BTreeSet::new();
1493            for entry in ignore::WalkBuilder::new(root)
1494                .hidden(false)
1495                .parents(false)
1496                .git_global(false)
1497                .require_git(false)
1498                .ignore(false)
1499                .add_custom_ignore_filename(".nxignore")
1500                .follow_links(false)
1501                .filter_entry(|entry| {
1502                    !matches!(
1503                        entry.file_name().to_str(),
1504                        Some(".git" | ".qk" | "node_modules")
1505                    )
1506                })
1507                .build()
1508            {
1509                let entry = entry?;
1510                if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
1511                    files.insert(paths::relative(root, entry.path())?);
1512                }
1513            }
1514            files
1515        }
1516    };
1517    let ignore = SourceIgnore::new(root)?;
1518    Ok(candidates
1519        .into_iter()
1520        .filter(|path| {
1521            !ignore.matches(path)
1522                && !path
1523                    .split('/')
1524                    .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
1525        })
1526        .collect())
1527}
1528
1529/// The text of `path` as a `json` input keys it: only `fields` when given,
1530/// without `exclude`. A `.yaml` or `.yml` file is read as YAML, and any other
1531/// as JSON or JSONC. `None` when it cannot be read so.
1532pub(crate) fn json_projection(path: &str, text: &str, fields: &JsonFields) -> Option<Value> {
1533    let mut value: Value = if path.ends_with(".yaml") || path.ends_with(".yml") {
1534        serde_yaml_ng::from_str(text).ok()?
1535    } else {
1536        jsonc_parser::parse_to_serde_value(text, &Default::default()).ok()?
1537    };
1538    if let Some(fields) = &fields.fields {
1539        let mut kept = json!({});
1540        for field in fields {
1541            if let Some(found) = json_path(&value, field) {
1542                set_json_path(&mut kept, field, found.clone());
1543            }
1544        }
1545        value = kept;
1546    }
1547    for field in &fields.exclude {
1548        remove_json_path(&mut value, field);
1549    }
1550    Some(value)
1551}
1552
1553fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
1554    path.split('.')
1555        .try_fold(value, |value, part| value.get(part))
1556}
1557
1558fn set_json_path(target: &mut Value, path: &str, value: Value) {
1559    let mut parts = path.split('.').peekable();
1560    let mut current = target;
1561    while let Some(part) = parts.next() {
1562        let Value::Object(object) = current else {
1563            return;
1564        };
1565        if parts.peek().is_none() {
1566            object.insert(part.to_owned(), value);
1567            return;
1568        }
1569        current = object.entry(part).or_insert_with(|| json!({}));
1570    }
1571}
1572
1573fn remove_json_path(target: &mut Value, path: &str) {
1574    let (parent, last) = match path.rsplit_once('.') {
1575        Some((parent, last)) => (Some(parent), last),
1576        None => (None, path),
1577    };
1578    let parent = match parent {
1579        Some(parent) => parent
1580            .split('.')
1581            .try_fold(&mut *target, |value, part| value.get_mut(part)),
1582        None => Some(target),
1583    };
1584    if let Some(Value::Object(object)) = parent {
1585        object.remove(last);
1586    }
1587}
1588
1589struct FilePattern {
1590    matcher: Arc<Pattern>,
1591    prefix: String,
1592    /// The glob after `prefix`, the literal directory it starts with; `None`
1593    /// when the pattern names one path.
1594    rest: Option<String>,
1595    excluded: bool,
1596    /// The project a project fileset selects the files of.
1597    owner: Option<String>,
1598}
1599
1600/// The patterns of a task's declared filesets, by the scope they select in:
1601/// kept to tell whether a file where there is none would be selected, as
1602/// [`Coverage`] asks.
1603#[derive(Default)]
1604pub struct Selection {
1605    scopes: Vec<Vec<FilePattern>>,
1606}
1607
1608impl Selection {
1609    /// Whether a file at `path` would be selected, gitignore aside: an
1610    /// included pattern of a scope matches it, and none of that scope's
1611    /// exclusions does.
1612    fn selects(&self, snapshot: &Snapshot, path: &str) -> bool {
1613        let applies = |pattern: &FilePattern| {
1614            pattern.matcher.is_match(path)
1615                && (pattern.owner.is_none() || snapshot.owner(path) == pattern.owner.as_deref())
1616        };
1617        self.scopes.iter().any(|patterns| {
1618            patterns
1619                .iter()
1620                .any(|pattern| !pattern.excluded && applies(pattern))
1621                && !patterns
1622                    .iter()
1623                    .any(|pattern| pattern.excluded && applies(pattern))
1624        })
1625    }
1626
1627    /// Whether every entry `directory` could hold, at any depth, would be
1628    /// selected, gitignore aside: an included pattern of a scope takes all of
1629    /// what is under a directory at or above it, and none of that scope's
1630    /// exclusions reaches into it. `""` is the workspace root.
1631    fn selects_every_entry(&self, snapshot: &Snapshot, directory: &str) -> bool {
1632        let within = |prefix: &str| {
1633            prefix.is_empty()
1634                || directory == prefix
1635                || directory
1636                    .strip_prefix(prefix)
1637                    .is_some_and(|rest| rest.starts_with('/'))
1638        };
1639        let reaches = |prefix: &str| {
1640            within(prefix)
1641                || directory.is_empty()
1642                || prefix
1643                    .strip_prefix(directory)
1644                    .is_some_and(|rest| rest.starts_with('/'))
1645        };
1646        let owner = snapshot.owner(if directory.is_empty() { "." } else { directory });
1647        self.scopes.iter().any(|patterns| {
1648            patterns.iter().any(|pattern| {
1649                !pattern.excluded
1650                    && within(&pattern.prefix)
1651                    && matches!(pattern.rest.as_deref(), Some("**" | "**/*"))
1652                    && (pattern.owner.is_none() || owner == pattern.owner.as_deref())
1653            }) && !patterns
1654                .iter()
1655                .any(|pattern| pattern.excluded && reaches(&pattern.prefix))
1656        })
1657    }
1658}
1659
1660/// What a task's declared key holds of the workspace's files, to tell
1661/// whether it keys everything a recording says the task read, looked for
1662/// and listed: when it does, a result under the declared key is as sound as
1663/// qk's keys are without recordings. A file qk keys by part of its content,
1664/// such as a dependency's `package.json` or a `json` input, counts as keyed,
1665/// as it does for every task's key.
1666pub(crate) struct Coverage {
1667    resolved: Resolved,
1668    /// Each `dependentTasksOutputFiles` glob, with the declared outputs of
1669    /// the tasks it reads.
1670    dependency_outputs: Vec<(Arc<Pattern>, Vec<Outputs>)>,
1671}
1672
1673/// What `task`'s declared key covers: see [`Coverage`].
1674pub(crate) fn coverage(
1675    snapshot: &Snapshot,
1676    workspace: &Workspace,
1677    graph: &TaskGraph,
1678    task: &Task,
1679    prepared: &PreparedTask,
1680    cancelled: &AtomicBool,
1681) -> Result<Coverage> {
1682    let resolved = resolve(snapshot, workspace, task, Some(prepared), cancelled)?;
1683    let selections: Vec<(String, bool)> = resolved
1684        .dependency_output_inputs()
1685        .map(|(pattern, transitive)| (pattern.to_owned(), transitive))
1686        .collect();
1687    let mut dependency_outputs = Vec::new();
1688    for (pattern, transitive) in selections {
1689        let matcher = snapshot.pattern(&pattern, false)?;
1690        let selected = graph.output_dependencies(task, transitive);
1691        let mut outputs = Vec::new();
1692        for id in selected {
1693            let declared = Outputs::new(workspace, dependency_task(graph, &id)?)?;
1694            if declared.declared() {
1695                outputs.push(declared);
1696            }
1697        }
1698        dependency_outputs.push((matcher, outputs));
1699    }
1700    Ok(Coverage {
1701        resolved,
1702        dependency_outputs,
1703    })
1704}
1705
1706impl Coverage {
1707    /// Whether the declared key holds the content of the file at `path`, or
1708    /// what of it reaches tasks, by its declared inputs or by what qk adds.
1709    fn keyed(&self, path: &str) -> bool {
1710        let inferred = &self.resolved.inferred;
1711        self.resolved.is_whole_file(path)
1712            || self.resolved.is_json_input(path)
1713            || inferred.manifests.contains_key(path)
1714            || inferred.tsconfig.as_deref() == Some(path)
1715            || inferred.settings.contains(path)
1716            || inferred.project.contains(path)
1717            || (inferred.workspace_file && path == "pnpm-workspace.yaml")
1718            || (inferred.lockfile.is_some() && path == "pnpm-lock.yaml")
1719            || self.dependency_outputs.iter().any(|(matcher, outputs)| {
1720                matcher.is_match(path) && outputs.iter().any(|outputs| outputs.matches(path))
1721            })
1722    }
1723
1724    /// Whether the declared key covers every path of `set`: each file read
1725    /// or found is keyed, or a directory found holds a keyed file, so that
1726    /// its going changes the key; a file created where one was looked for,
1727    /// or where one read is gone since, would be selected, and is neither
1728    /// gitignored nor a declared output;
1729    /// and every entry a listed directory could gain or lose that is not
1730    /// gitignored would be selected, the directory being no declared output.
1731    /// A declared output never enters the declared files, unless a
1732    /// `dependentTasksOutputFiles` input keys it.
1733    /// `output` says whether a path is a declared output of any target.
1734    pub(crate) fn covers(
1735        &self,
1736        snapshot: &Snapshot,
1737        root: &Path,
1738        set: &crate::recorded::PathSet,
1739        output: &dyn Fn(&str) -> bool,
1740    ) -> bool {
1741        self.uncovered(snapshot, root, set, output).next().is_none()
1742    }
1743
1744    /// The paths of `set` the declared key does not cover, as
1745    /// [`Self::covers`] judges them, each with what the task did to it:
1746    /// `read`, `found`, `looked for` or `listed`.
1747    pub(crate) fn uncovered<'a>(
1748        &'a self,
1749        snapshot: &'a Snapshot,
1750        root: &'a Path,
1751        set: &'a crate::recorded::PathSet,
1752        output: &'a dyn Fn(&str) -> bool,
1753    ) -> impl Iterator<Item = (&'a str, &'static str)> + 'a {
1754        let selection = &self.resolved.declared.selection;
1755        fn directory(path: &str) -> &str {
1756            if path == "." { "" } else { path }
1757        }
1758        let holds_keyed = move |path: &str| {
1759            let path = directory(path);
1760            if path.is_empty() {
1761                return self.resolved.whole_files().next().is_some();
1762            }
1763            under(&self.resolved.declared.files, path).next().is_some()
1764                || under(&self.resolved.inferred.files, path).next().is_some()
1765        };
1766        // Whether a file created at `path` would enter the declared key.
1767        let selected = move |path: &str| {
1768            !snapshot.source_ignore.matches(path)
1769                && !output(path)
1770                && selection.selects(snapshot, path)
1771        };
1772        // A file the task read that is gone since is keyed as one it looked
1773        // for and did not find: its coming back changes the key. A path it
1774        // found may have been a directory, which the declared key leaves out
1775        // while empty, so its going is not judged so.
1776        let gone = move |path: &str| {
1777            root.join(path).symlink_metadata().is_err_and(|error| {
1778                matches!(
1779                    error.kind(),
1780                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1781                )
1782            })
1783        };
1784        let reads = set
1785            .reads
1786            .iter()
1787            .filter(move |path| !(self.keyed(path) || (gone(path) && selected(path))))
1788            .map(|path| (path.as_str(), "read"));
1789        let present = set
1790            .present
1791            .iter()
1792            .filter(move |path| {
1793                !(self.keyed(path) || (root.join(path).is_dir() && holds_keyed(path)))
1794            })
1795            .map(|path| (path.as_str(), "found"));
1796        let absent = set
1797            .absent
1798            .iter()
1799            .filter(move |path| !(self.keyed(path) || selected(path)))
1800            .map(|path| (path.as_str(), "looked for"));
1801        let listings = set
1802            .listings
1803            .iter()
1804            .filter(move |path| {
1805                let path = directory(path);
1806                !((path.is_empty() || !output(path))
1807                    && selection.selects_every_entry(snapshot, path))
1808            })
1809            .map(|path| (path.as_str(), "listed"));
1810        reads.chain(present).chain(absent).chain(listings)
1811    }
1812}
1813
1814/// A `json` glob, kept to select dependency outputs once they are written.
1815struct JsonGlob {
1816    matcher: Arc<Pattern>,
1817    prefix: String,
1818    /// The project whose files it selects, or `None` for the workspace's.
1819    owner: Option<String>,
1820    fields: JsonFields,
1821}
1822
1823#[derive(Default)]
1824struct FileSelection {
1825    patterns: Vec<FilePattern>,
1826    included: BTreeSet<String>,
1827    excluded: BTreeSet<String>,
1828}
1829
1830#[derive(PartialEq, Eq, PartialOrd, Ord)]
1831enum FileScope {
1832    Workspace,
1833    Project(String),
1834}
1835
1836struct Resolver<'a> {
1837    workspace: &'a Workspace,
1838    snapshot: &'a Snapshot,
1839    candidates: &'a BTreeSet<String>,
1840    selected: BTreeSet<String>,
1841    scopes: BTreeMap<FileScope, FileSelection>,
1842    values: BTreeMap<String, Value>,
1843    json_fields: BTreeMap<String, Vec<JsonFields>>,
1844    json_globs: Vec<JsonGlob>,
1845    named_stack: Vec<(String, String)>,
1846    /// Dependency named inputs already expanded for `^` inputs.
1847    expanded: BTreeSet<(String, String)>,
1848    external: BTreeSet<String>,
1849    /// `includeIgnored` globs, resolved, by the project that reads them and
1850    /// whether they came with `dependencies: true`: each is one group, whose
1851    /// negations filter all of its positives.
1852    ignored: BTreeMap<(String, bool), Vec<String>>,
1853    external_declared: bool,
1854    /// Every project whose inputs were read, as Nx hashes each one's
1855    /// configuration.
1856    projects: BTreeSet<String>,
1857    /// `None` resolves which inputs a task has without evaluating env or
1858    /// runtime inputs.
1859    prepared: Option<&'a PreparedTask>,
1860    cancelled: &'a AtomicBool,
1861}
1862
1863impl<'a> Resolver<'a> {
1864    fn new(
1865        snapshot: &'a Snapshot,
1866        workspace: &'a Workspace,
1867        prepared: Option<&'a PreparedTask>,
1868        cancelled: &'a AtomicBool,
1869        candidates: &'a BTreeSet<String>,
1870    ) -> Self {
1871        Self {
1872            workspace,
1873            snapshot,
1874            candidates,
1875            selected: BTreeSet::new(),
1876            scopes: BTreeMap::new(),
1877            values: BTreeMap::new(),
1878            json_fields: BTreeMap::new(),
1879            json_globs: Vec::new(),
1880            named_stack: Vec::new(),
1881            expanded: BTreeSet::new(),
1882            external: BTreeSet::new(),
1883            ignored: BTreeMap::new(),
1884            external_declared: false,
1885            projects: BTreeSet::new(),
1886            prepared,
1887            cancelled,
1888        }
1889    }
1890
1891    fn task_inputs(&mut self, task: &Task) -> Result<()> {
1892        self.projects.insert(task.project.clone());
1893        for input in declared_inputs(task) {
1894            self.input(&task.project, &input)?;
1895        }
1896        // As in Nx, a continuous task serving this one counts its own inputs
1897        // here.
1898        let snapshot = self.snapshot;
1899        for (project, inputs) in snapshot.servers.get(&task.id).into_iter().flatten() {
1900            for input in inputs {
1901                self.input(project, input)
1902                    .with_context(|| format!("inputs of {project}, which serves this task"))?;
1903            }
1904        }
1905        Ok(())
1906    }
1907
1908    fn input(&mut self, project: &str, input: &Value) -> Result<()> {
1909        self.projects.insert(project.to_owned());
1910        // Nx 24's `always` keeps an input in a hash taken from an Ultracache
1911        // recording. qk hashes no recordings, so the input reads as without it.
1912        if let Value::Object(object) = input
1913            && let Some(always) = object.get("always")
1914        {
1915            // As in Nx, `always` keeps an input on purpose, so a project
1916            // pattern it selects nothing with is a mistake.
1917            if always.as_bool().context("input always must be boolean")?
1918                && let Some(Value::String(name)) = object.get("input")
1919                && let Some(projects) = object.get("projects")
1920            {
1921                let patterns: Vec<String> = match projects {
1922                    Value::String(pattern) => vec![pattern.clone()],
1923                    other => serde_json::from_value(other.clone())
1924                        .context("input projects must be a string or an array")?,
1925                };
1926                for pattern in patterns.iter().filter(|pattern| {
1927                    !pattern.starts_with('!')
1928                        && !matches!(pattern.as_str(), "self" | "dependencies")
1929                }) {
1930                    if qk_graph::select_projects(
1931                        &self.workspace.projects,
1932                        std::slice::from_ref(pattern),
1933                        &[],
1934                    )?
1935                    .is_empty()
1936                    {
1937                        bail!(
1938                            "the input {{\"input\": \"{name}\", \"projects\"}} with \"always\": true selects \"{pattern}\", which matches no project"
1939                        );
1940                    }
1941                }
1942            }
1943            let mut object = object.clone();
1944            object.remove("always");
1945            return self.input(project, &Value::Object(object));
1946        }
1947        match input {
1948            // As in Nx, `^{projectRoot}/…` and `^{workspaceRoot}/…` are a
1949            // fileset of every project the project depends on, directly or
1950            // not, and `^name` is `name` of each of them.
1951            Value::String(name)
1952                if name.starts_with("^{projectRoot}") || name.starts_with("^{workspaceRoot}") =>
1953            {
1954                self.dependency_fileset(project, &name[1..])?;
1955            }
1956            Value::String(name) if name.starts_with('^') => {
1957                self.dependencies_named(project, &name[1..])?;
1958            }
1959            // The object spellings of named inputs, as Nx reads them.
1960            Value::Object(object)
1961                if object.contains_key("input") && !object.contains_key("fileset") =>
1962            {
1963                let name = object["input"]
1964                    .as_str()
1965                    .context("input must name a named input")?;
1966                let projects = object.get("projects");
1967                let dependencies = object.get("dependencies") == Some(&json!(true))
1968                    || projects == Some(&json!("dependencies"));
1969                let listed = projects.filter(|projects| {
1970                    **projects != json!("self") && **projects != json!("dependencies")
1971                });
1972                if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
1973                    bail!("named inputs can only refer to named inputs of their own project");
1974                }
1975                if let Some(listed) = listed {
1976                    let patterns: Vec<String> = match listed {
1977                        Value::String(pattern) => vec![pattern.clone()],
1978                        other => serde_json::from_value(other.clone())
1979                            .context("input projects must be a string or an array")?,
1980                    };
1981                    // Nx's native hasher reads these patterns, not the
1982                    // `findMatchingProjects` the rest of Nx uses.
1983                    for selected in qk_config::find_matching_projects(
1984                        &patterns,
1985                        &self.workspace.projects,
1986                        qk_config::Reader::Native,
1987                    ) {
1988                        // As in Nx 24, `dependencies: true` beside `projects`
1989                        // is each selected project's `^name`, not its own.
1990                        if dependencies {
1991                            self.dependencies_named(&selected, name)?;
1992                        } else if self.expanded.insert((selected.clone(), name.to_owned())) {
1993                            self.named(&selected, name)?;
1994                        }
1995                    }
1996                } else if dependencies {
1997                    self.dependencies_named(project, name)?;
1998                } else {
1999                    self.named(project, name)?;
2000                }
2001            }
2002            Value::Object(object) if object.contains_key("fileset") => {
2003                if let Some(key) = object.keys().find(|key| {
2004                    !matches!(key.as_str(), "fileset" | "dependencies" | "includeIgnored")
2005                }) {
2006                    bail!("unsupported fileset input field {key:?}");
2007                }
2008                let flag = |name: &str| {
2009                    object
2010                        .get(name)
2011                        .map(|value| {
2012                            value
2013                                .as_bool()
2014                                .with_context(|| format!("{name} must be boolean"))
2015                        })
2016                        .transpose()
2017                        .map(Option::unwrap_or_default)
2018                };
2019                let (dependencies, include_ignored) =
2020                    (flag("dependencies")?, flag("includeIgnored")?);
2021                let pattern = object["fileset"]
2022                    .as_str()
2023                    .context("fileset input must be a glob")?
2024                    .to_owned();
2025                validate_fileset(&pattern)?;
2026                match (dependencies, include_ignored) {
2027                    (false, false) => self.fileset(project, &pattern)?,
2028                    (true, false) => self.dependency_fileset(project, &pattern)?,
2029                    (false, true) => self.ignored_fileset(project, &pattern, false),
2030                    (true, true) => {
2031                        if !self.named_stack.is_empty() {
2032                            bail!(
2033                                "named inputs can only refer to named inputs of their own project"
2034                            );
2035                        }
2036                        for dependency in self.dependency_closure(project) {
2037                            self.ignored_fileset(&dependency, &pattern, true);
2038                        }
2039                    }
2040                }
2041            }
2042            Value::Object(object)
2043                if object.len() == 1 && object.contains_key("workingDirectory") =>
2044            {
2045                let mode = object["workingDirectory"].as_str().unwrap_or_default();
2046                let directory = std::env::current_dir()?;
2047                let value = match mode {
2048                    "absolute" => directory.to_string_lossy().into_owned(),
2049                    "relative" => {
2050                        let root = self.workspace.root.canonicalize()?;
2051                        let directory = directory.canonicalize()?;
2052                        paths::relative(&root, &directory)
2053                            .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
2054                    }
2055                    _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
2056                };
2057                self.values
2058                    .insert(format!("workingDirectory:{mode}"), json!(value));
2059            }
2060            Value::Object(object) if object.contains_key("json") => {
2061                self.json(project, object)?;
2062            }
2063            // As in Nx, a string names a named input when there is one, and is
2064            // a fileset otherwise.
2065            Value::String(name) if self.has_named(project, name) => self.named(project, name)?,
2066            Value::String(pattern) => {
2067                validate_fileset(pattern)?;
2068                self.fileset(project, pattern)?
2069            }
2070            Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
2071                let name = object["env"]
2072                    .as_str()
2073                    .context("env input must name a variable")?;
2074                let Some(prepared) = self.prepared else {
2075                    self.values.insert(format!("env:{name}"), Value::Null);
2076                    return Ok(());
2077                };
2078                let value = prepared
2079                    .env
2080                    .get(std::ffi::OsStr::new(name))
2081                    .map(|value| {
2082                        value
2083                            .to_str()
2084                            .map(str::to_owned)
2085                            .context("declared env input must be UTF-8")
2086                    })
2087                    .transpose()?;
2088                self.values.insert(format!("env:{name}"), json!(value));
2089            }
2090            Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
2091                let command = object["runtime"]
2092                    .as_str()
2093                    .context("runtime input must be a command")?;
2094                let Some(prepared) = self.prepared else {
2095                    self.values
2096                        .insert(format!("runtime:{command}"), Value::Null);
2097                    return Ok(());
2098                };
2099                let memo = RuntimeKey {
2100                    command: command.into(),
2101                    environment: prepared.runtime_env.clone(),
2102                };
2103                let slot = self
2104                    .snapshot
2105                    .runtime
2106                    .lock()
2107                    .unwrap()
2108                    .entry(memo)
2109                    .or_default()
2110                    .clone();
2111                let mut saved = slot.lock().unwrap();
2112                if let Some(value) = saved.as_ref() {
2113                    self.values
2114                        .insert(format!("runtime:{command}"), value.clone());
2115                    return Ok(());
2116                }
2117                let _profile = crate::profile::span(command, "runtime");
2118                let mut prepared = prepared.clone();
2119                prepared.env = prepared.runtime_env.clone();
2120                prepared.execution.clear();
2121                prepared.node_path = None;
2122                prepared.commands = vec![command.into()];
2123                prepared.cwd = self.workspace.root.clone();
2124                prepared.parallel = false;
2125                // Its output is hashed, so it must not depend on a terminal.
2126                prepared.pseudo_terminal = false;
2127                let log = tempfile::NamedTempFile::new()?;
2128                let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
2129                if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
2130                {
2131                    bail!("runtime input did not succeed");
2132                }
2133                let mut stdout = blake3::Hasher::new();
2134                let mut stderr = blake3::Hasher::new();
2135                read_capture(File::open(log.path())?, |error, bytes| {
2136                    if error {
2137                        stderr.update(bytes);
2138                    } else {
2139                        stdout.update(bytes);
2140                    }
2141                    Ok(())
2142                })?;
2143                let value = json!([
2144                    stdout.finalize().to_hex().to_string(),
2145                    stderr.finalize().to_hex().to_string()
2146                ]);
2147                *saved = Some(value.clone());
2148                self.values.insert(format!("runtime:{command}"), value);
2149            }
2150            Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
2151                if object
2152                    .keys()
2153                    .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
2154                {
2155                    bail!("unsupported output input field");
2156                }
2157                let pattern = object["dependentTasksOutputFiles"]
2158                    .as_str()
2159                    .context("dependentTasksOutputFiles must be a glob")?;
2160                self.snapshot.pattern(pattern, false)?;
2161                let transitive = object
2162                    .get("transitive")
2163                    .map(|value| value.as_bool().context("transitive must be boolean"))
2164                    .transpose()?
2165                    .unwrap_or(false);
2166                // Resolved before execution too, so inspection can display the
2167                // selection without reading generated files. dependency_keys
2168                // hashes these files after the dependencies finish.
2169                let value = self
2170                    .values
2171                    .entry(format!("dependentTasksOutputFiles:{pattern}"))
2172                    .or_insert(json!(false));
2173                if transitive {
2174                    *value = json!(true);
2175                }
2176            }
2177            Value::Object(object)
2178                if object.len() == 1 && object.contains_key("externalDependencies") =>
2179            {
2180                let names: Vec<String> =
2181                    serde_json::from_value(object["externalDependencies"].clone())?;
2182                // As in Nx, a workspace project is no external dependency.
2183                if let Some(name) = names
2184                    .iter()
2185                    .find(|name| self.workspace.projects.contains_key(*name))
2186                {
2187                    if self.snapshot.projects.dependencies[project]
2188                        .iter()
2189                        .any(|edge| edge.target == *name)
2190                    {
2191                        bail!(
2192                            "the externalDependency {name:?} of {project} is not an external node and is already a dependency; remove it from the externalDependencies inputs"
2193                        );
2194                    }
2195                    bail!(
2196                        "the externalDependency {name:?} of {project} is not an external node; if it is a dependency, add it to the project's implicitDependencies"
2197                    );
2198                }
2199                self.external.extend(names);
2200                self.external_declared = true;
2201            }
2202            _ => bail!("unsupported task input declaration"),
2203        }
2204        Ok(())
2205    }
2206
2207    fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
2208        self.projects.insert(project.to_owned());
2209        let (exclude, pattern) = pattern
2210            .strip_prefix('!')
2211            .map(|pattern| (true, pattern))
2212            .unwrap_or((false, pattern));
2213        // Nx hashes workspace files and each project's files in separate
2214        // scopes, a project's being only the files it owns: those of a project
2215        // nested in its root are that project's. A fileset is the project's
2216        // when it starts from `{projectRoot}/`, and the workspace's otherwise,
2217        // where only one starting from `{workspaceRoot}/` selects anything.
2218        let (scope, owner) = if pattern.starts_with("{projectRoot}/") {
2219            (
2220                FileScope::Project(project.to_owned()),
2221                Some(project.to_owned()),
2222            )
2223        } else if pattern.starts_with("{workspaceRoot}/") {
2224            (FileScope::Workspace, None)
2225        } else {
2226            return Ok(());
2227        };
2228        let pattern = paths::expand_glob(self.workspace, project, pattern)?;
2229        let matcher = self.snapshot.fileset_pattern(&pattern, exclude)?;
2230        let (prefix, rest) = crate::ignored::partition(pattern.trim_end_matches('/'));
2231        let rest = rest.map(str::to_owned);
2232        let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
2233            Box::new(self.candidates.iter())
2234        } else {
2235            Box::new(under(self.candidates, &prefix))
2236        };
2237        let snapshot = self.snapshot;
2238        let selection = self.scopes.entry(scope).or_default();
2239        let selected = if exclude {
2240            &mut selection.excluded
2241        } else {
2242            &mut selection.included
2243        };
2244        selected.extend(
2245            candidates
2246                .filter(|file| matcher.is_match(file))
2247                .filter(|file| owner.is_none() || snapshot.owner(file) == owner.as_deref())
2248                .cloned(),
2249        );
2250        selection.patterns.push(FilePattern {
2251            matcher,
2252            prefix,
2253            rest,
2254            excluded: exclude,
2255            owner,
2256        });
2257        Ok(())
2258    }
2259
2260    /// An `includeIgnored` glob of `project`, with its tokens resolved as
2261    /// Nx's `resolve_files_glob` resolves them, to read from disk later.
2262    fn ignored_fileset(&mut self, project: &str, pattern: &str, dependencies: bool) {
2263        // The root and name go in escaped, as for every fileset; Nx inserts
2264        // them as they are.
2265        let root = &self.workspace.projects[project].root;
2266        let resolved = if root == "." {
2267            pattern.replace("{projectRoot}/", "")
2268        } else {
2269            pattern.replace("{projectRoot}", &crate::glob::escape(root))
2270        }
2271        .replace("{projectName}", &crate::glob::escape(project));
2272        let resolved = match resolved.strip_prefix("!{workspaceRoot}/") {
2273            Some(rest) => format!("!{rest}"),
2274            None => resolved
2275                .strip_prefix("{workspaceRoot}/")
2276                .map(str::to_owned)
2277                .unwrap_or(resolved),
2278        };
2279        self.ignored
2280            .entry((project.to_owned(), dependencies))
2281            .or_default()
2282            .push(resolved);
2283    }
2284
2285    /// A fileset of every project `project` depends on, directly or not.
2286    fn dependency_fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
2287        if !self.named_stack.is_empty() {
2288            bail!("named inputs can only refer to named inputs of their own project");
2289        }
2290        for dependency in self.dependency_closure(project) {
2291            self.fileset(&dependency, pattern)?;
2292        }
2293        Ok(())
2294    }
2295
2296    /// Whether `name` names one of `project`'s named inputs.
2297    fn has_named(&self, project: &str, name: &str) -> bool {
2298        name == "default"
2299            || self.workspace.projects[project]
2300                .named_inputs
2301                .contains_key(name)
2302    }
2303
2304    /// Every project `project` depends on, directly or not.
2305    fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
2306        let mut closure = BTreeSet::new();
2307        let mut pending = vec![project.to_owned()];
2308        while let Some(current) = pending.pop() {
2309            for edge in &self.snapshot.projects.dependencies[&current] {
2310                if edge.target != project && closure.insert(edge.target.clone()) {
2311                    pending.push(edge.target.clone());
2312                }
2313            }
2314        }
2315        closure
2316    }
2317
2318    /// As in Nx, `^name` is `name` of every project the project depends on.
2319    fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
2320        for dependency in self.dependency_closure(project) {
2321            if self.expanded.insert((dependency.clone(), name.to_owned())) {
2322                self.named(&dependency, name)?;
2323            }
2324        }
2325        Ok(())
2326    }
2327
2328    /// A JSON input: the fields of a JSON file, rather than all of it, or of
2329    /// each file a glob selects. `fields` keeps only the named dotted paths;
2330    /// `excludeFields` removes them.
2331    fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
2332        for key in object.keys() {
2333            if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
2334                bail!("unsupported json input field {key:?}");
2335            }
2336        }
2337        let declared = object["json"]
2338            .as_str()
2339            .context("json input must name a file")?;
2340        validate_fileset(declared)?;
2341        let path = paths::expand_glob(self.workspace, project, declared)?;
2342        let list = |name: &str| -> Result<Option<Vec<String>>> {
2343            object
2344                .get(name)
2345                .map(|value| {
2346                    serde_json::from_value(value.clone())
2347                        .with_context(|| format!("json input {name} must be an array of strings"))
2348                })
2349                .transpose()
2350        };
2351        let fields = JsonFields {
2352            fields: list("fields")?,
2353            exclude: list("excludeFields")?.unwrap_or_default(),
2354        };
2355        if crate::glob::names_a_path(&path) {
2356            let path = crate::glob::literal_path(&path);
2357            paths::validate_path(&path)?;
2358            return self.json_file(path, &fields);
2359        }
2360        // As in Nx, a glob selects the files a fileset from the same root
2361        // would: the project's own, or the workspace's.
2362        let owner = if declared.starts_with("{projectRoot}") {
2363            Some(project)
2364        } else if declared.starts_with("{workspaceRoot}/") {
2365            None
2366        } else {
2367            return Ok(());
2368        };
2369        let matcher = self.snapshot.pattern(&path, false)?;
2370        let snapshot = self.snapshot;
2371        let matched: Vec<String> = self
2372            .candidates
2373            .iter()
2374            .filter(|file| matcher.is_match(file))
2375            .filter(|file| owner.is_none() || snapshot.owner(file) == owner)
2376            .cloned()
2377            .collect();
2378        for file in matched {
2379            self.json_file(file, &fields)?;
2380        }
2381        self.json_globs.push(JsonGlob {
2382            matcher,
2383            prefix: crate::ignored::partition(&path).0,
2384            owner: owner.map(str::to_owned),
2385            fields,
2386        });
2387        Ok(())
2388    }
2389
2390    /// One file a `json` input selects, keyed by its fields. Several inputs
2391    /// may select different fields of one file: each counts, as in Nx since
2392    /// 24.0.0-rc.0, where one used to replace the other.
2393    fn json_file(&mut self, path: String, fields: &JsonFields) -> Result<()> {
2394        let text = match std::fs::read_to_string(self.workspace.root.join(&path)) {
2395            Ok(text) => Some(text),
2396            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2397            Err(error) => return Err(error.into()),
2398        };
2399        let mut selections = self.json_fields.get(&path).cloned().unwrap_or_default();
2400        if !selections.contains(fields) {
2401            selections.push(fields.clone());
2402            // In one order, whichever input came first.
2403            selections.sort();
2404        }
2405        let mut values = Vec::new();
2406        for selection in &selections {
2407            values.push(match &text {
2408                Some(text) => match json_projection(&path, text, selection) {
2409                    Some(value) => value,
2410                    // As in Nx, a file it cannot read is keyed by its content,
2411                    // without the field filters. Nx reads no YAML, so it keys
2412                    // a YAML file whole where qk keys its fields.
2413                    None => {
2414                        self.selected.insert(path);
2415                        return Ok(());
2416                    }
2417                },
2418                None => Value::Null,
2419            });
2420        }
2421        // One selection keys its fields as before; several, each in turn.
2422        let value = if values.len() == 1 {
2423            values.remove(0)
2424        } else {
2425            Value::Array(values)
2426        };
2427        self.values.insert(format!("json:{path}"), value);
2428        self.json_fields.insert(path, selections);
2429        Ok(())
2430    }
2431
2432    fn named(&mut self, project: &str, name: &str) -> Result<()> {
2433        let identity = (project.to_owned(), name.to_owned());
2434        if self.named_stack.contains(&identity) {
2435            bail!("named input cycle at {project}:{name}");
2436        }
2437        self.named_stack.push(identity);
2438        let inputs = self.workspace.projects[project]
2439            .named_inputs
2440            .get(name)
2441            .cloned()
2442            .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
2443            .with_context(|| format!("unknown named input {name:?}"))?;
2444        for input in &inputs {
2445            self.input(project, input)?;
2446        }
2447        self.named_stack.pop();
2448        Ok(())
2449    }
2450}
2451
2452/// As Nx's `validate_file_set`: a fileset starts from `{projectRoot}` or
2453/// `{workspaceRoot}`, after its `!`.
2454fn validate_fileset(fileset: &str) -> Result<()> {
2455    let body = fileset.strip_prefix('!').unwrap_or(fileset);
2456    if !body.starts_with("{projectRoot}") && !body.starts_with("{workspaceRoot}") {
2457        bail!(
2458            "{fileset:?} is an invalid fileset: filesets have to start with either {{workspaceRoot}} or {{projectRoot}}, and a named input must be defined"
2459        );
2460    }
2461    Ok(())
2462}
2463
2464/// A task's declared inputs, or Nx's default when it declares none.
2465fn declared_inputs(task: &Task) -> Vec<Value> {
2466    task.definition
2467        .inputs
2468        .clone()
2469        .unwrap_or_else(|| vec![json!("default"), json!("^default")])
2470}
2471
2472/// The continuous tasks serving a task, whose inputs count as its own, as Nx
2473/// hashes them: its continuous dependencies, theirs in turn, and so on, each
2474/// by its project and declared inputs. A dependency declared with
2475/// `inputs: false` is not followed, though a server it leads to still counts
2476/// when another way reaches it.
2477fn servers(graph: &TaskGraph, id: &str) -> Vec<(String, Vec<Value>)> {
2478    let mut servers = Vec::new();
2479    let mut visited = BTreeSet::from([id]);
2480    let mut pending = std::collections::VecDeque::from([id]);
2481    while let Some(current) = pending.pop_front() {
2482        let task = &graph.tasks[current];
2483        for dependency in &task.dependencies {
2484            let Some(server) = graph.tasks.get(dependency) else {
2485                continue;
2486            };
2487            if server.definition.continuous != Some(true)
2488                || !task.edge(dependency).inputs
2489                || !visited.insert(dependency)
2490            {
2491                continue;
2492            }
2493            servers.push((server.project.clone(), declared_inputs(server)));
2494            pending.push_back(dependency);
2495        }
2496    }
2497    servers
2498}
2499
2500pub fn resolve(
2501    snapshot: &Snapshot,
2502    workspace: &Workspace,
2503    task: &Task,
2504    prepared: Option<&PreparedTask>,
2505    cancelled: &AtomicBool,
2506) -> Result<Resolved> {
2507    resolve_candidates(
2508        snapshot,
2509        workspace,
2510        task,
2511        prepared,
2512        cancelled,
2513        &snapshot.files,
2514        &snapshot.source_ignore,
2515    )
2516}
2517
2518fn resolve_candidates(
2519    snapshot: &Snapshot,
2520    workspace: &Workspace,
2521    task: &Task,
2522    prepared: Option<&PreparedTask>,
2523    cancelled: &AtomicBool,
2524    candidates: &BTreeSet<String>,
2525    source_ignore: &SourceIgnore,
2526) -> Result<Resolved> {
2527    let mut resolver = Resolver::new(snapshot, workspace, prepared, cancelled, candidates);
2528    resolver.task_inputs(task)?;
2529    // Dependency outputs may also be declared sources, for filesets and `json`
2530    // globs alike. Downstream and unrelated
2531    // outputs remain artifacts, so producing them cannot invalidate an upstream task.
2532    let mut upstream = BTreeSet::new();
2533    let mut pending: Vec<_> = task.dependencies.iter().cloned().collect();
2534    while let Some(id) = pending.pop() {
2535        if upstream.insert(id.clone())
2536            && let Some(dependencies) = snapshot.task_dependencies.get(&id)
2537        {
2538            pending.extend(dependencies.iter().cloned());
2539        }
2540    }
2541    let own_outputs = Outputs::new(workspace, task)
2542        .ok()
2543        .filter(Outputs::is_explicit);
2544    let mut producers = BTreeSet::new();
2545    for (producer, declared) in &snapshot.generated_tasks {
2546        if producer.id == task.id || !upstream.contains(&producer.id) {
2547            continue;
2548        }
2549        let overlaps = |a: &str, b: &str| {
2550            a.is_empty()
2551                || a == b
2552                || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
2553                || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
2554        };
2555        let relevant = resolver
2556            .scopes
2557            .values()
2558            .flat_map(|scope| &scope.patterns)
2559            .filter(|pattern| !pattern.excluded)
2560            .map(|pattern| &pattern.prefix)
2561            .chain(resolver.json_globs.iter().map(|glob| &glob.prefix))
2562            .any(|prefix| {
2563                declared.anchors().any(|anchor| {
2564                    overlaps(prefix, anchor)
2565                        && (!source_ignore.matches_directory(anchor)
2566                            || !workspace.root.join(anchor).is_dir())
2567                })
2568            });
2569        if !relevant {
2570            continue;
2571        }
2572        producers.insert(producer.id.clone());
2573        let outputs = snapshot.dependency_outputs(workspace, producer)?;
2574        let mut json = Vec::new();
2575        for path in &outputs.files {
2576            if source_ignore.matches(path)
2577                || own_outputs.as_ref().is_some_and(|own| own.matches(path))
2578            {
2579                continue;
2580            }
2581            json.extend(
2582                resolver
2583                    .json_globs
2584                    .iter()
2585                    .filter(|glob| {
2586                        glob.matcher.is_match(path)
2587                            && (glob.owner.is_none()
2588                                || snapshot.owner(path) == glob.owner.as_deref())
2589                    })
2590                    .map(|glob| (path.clone(), glob.fields.clone())),
2591            );
2592            for selection in resolver.scopes.values_mut() {
2593                for pattern in &selection.patterns {
2594                    if pattern.matcher.is_match(path)
2595                        && (pattern.owner.is_none()
2596                            || snapshot.owner(path) == pattern.owner.as_deref())
2597                    {
2598                        if pattern.excluded {
2599                            selection.excluded.insert(path.clone());
2600                        } else {
2601                            selection.included.insert(path.clone());
2602                        }
2603                    }
2604                }
2605            }
2606        }
2607        for (path, fields) in json {
2608            resolver.json_file(path, &fields)?;
2609        }
2610    }
2611    // A changed file affected selection adds counts whatever produces it, as
2612    // in Nx, where a declared output is a file like any other.
2613    for selection in resolver.scopes.values() {
2614        resolver.selected.extend(
2615            selection
2616                .included
2617                .difference(&selection.excluded)
2618                .filter(|path| {
2619                    snapshot.extra_candidates.contains(*path)
2620                        || own_outputs.as_ref().is_none_or(|own| !own.matches(path))
2621                })
2622                .cloned(),
2623        );
2624    }
2625    // `includeIgnored` groups read the disk, gitignored files included, and
2626    // a changed path they name counts whether or not it is there. As in Nx,
2627    // one reading another task's declared outputs makes that task a producer.
2628    let mut ignored = BTreeSet::new();
2629    for ((project, _), globs) in &resolver.ignored {
2630        let group = Group::new(
2631            project,
2632            globs,
2633            workspace
2634                .projects
2635                .values()
2636                .map(|project| project.root.as_str()),
2637        )?;
2638        let found = group.expand(&workspace.root)?;
2639        ignored.extend(
2640            found
2641                .into_iter()
2642                .chain(
2643                    snapshot
2644                        .extra_candidates
2645                        .iter()
2646                        .filter(|path| group.matches(path))
2647                        .cloned(),
2648                )
2649                // A task's own outputs are no input of it, as for any fileset,
2650                // and no task's scratch is any task's.
2651                .filter(|path| own_outputs.as_ref().is_none_or(|own| !own.matches(path)))
2652                .filter(|path| !snapshot.scratch.iter().any(|scratch| scratch.matches(path))),
2653        );
2654        for (producer, declared) in &snapshot.generated_tasks {
2655            if producer.id != task.id
2656                && upstream.contains(&producer.id)
2657                && declared.declared()
2658                && reads_outputs(&group, declared)
2659            {
2660                producers.insert(producer.id.clone());
2661            }
2662        }
2663    }
2664    resolver.selected.extend(ignored.iter().cloned());
2665    let mut declared = Declared {
2666        files: resolver.selected,
2667        ignored,
2668        values: resolver.values,
2669        json_fields: resolver.json_fields,
2670        external: resolver.external,
2671        external_declared: resolver.external_declared,
2672        projects: resolver.projects,
2673        producers,
2674        selection: Selection {
2675            scopes: resolver
2676                .scopes
2677                .into_values()
2678                .map(|selection| selection.patterns)
2679                .collect(),
2680        },
2681    };
2682    // `dependentTasksOutputFiles` reads its direct dependencies' declared
2683    // outputs, or with `transitive` those of every task it depends on.
2684    let reads: Vec<bool> = declared
2685        .values
2686        .iter()
2687        .filter(|(name, _)| name.starts_with("dependentTasksOutputFiles:"))
2688        .map(|(_, transitive)| transitive == &Value::Bool(true))
2689        .collect();
2690    if !reads.is_empty() {
2691        let transitive = reads.contains(&true);
2692        for (producer, outputs) in &snapshot.generated_tasks {
2693            let read = if transitive {
2694                upstream.contains(&producer.id)
2695            } else {
2696                task.dependencies.contains(&producer.id)
2697            };
2698            if read && outputs.declared() {
2699                declared.producers.insert(producer.id.clone());
2700            }
2701        }
2702    }
2703    let mut inferred = infer(snapshot, workspace, task)?;
2704    // As in Nx, a task naming its external dependencies hashes those in place
2705    // of every installed package, so the root importer's installs count only
2706    // for the root project's own tasks.
2707    if declared.external_declared
2708        && let Some(importers) = &mut inferred.lockfile
2709    {
2710        let root = workspace.projects[&task.project].root == ".";
2711        if !root {
2712            importers.remove(".");
2713        }
2714    }
2715    Ok(join(declared, inferred))
2716}
2717
2718/// Whether an `includeIgnored` group may read files `outputs` declares, as
2719/// Nx's `GlobShape::may_read` judges it from the globs alone: a positive's
2720/// directory and an output's meet, the one holding the other, unless the
2721/// output lies in a project the positive does not reach, and their
2722/// extensions, where both have one, agree.
2723fn reads_outputs(group: &Group, outputs: &Outputs) -> bool {
2724    let holds = |outer: &str, inner: &str| {
2725        outer.is_empty()
2726            || inner == outer
2727            || inner
2728                .strip_prefix(outer)
2729                .is_some_and(|rest| rest.starts_with('/'))
2730    };
2731    group.reads().any(|(root, extension)| {
2732        outputs.globs().iter().any(|output| {
2733            let (prefix, _) = crate::ignored::partition(output);
2734            (holds(root, &prefix) || holds(&prefix, root))
2735                && !group
2736                    .skipped_below(root)
2737                    .iter()
2738                    .any(|project| holds(project, &prefix))
2739                && match (extension, crate::ignored::extension(output)) {
2740                    (Some(read), Some(written)) => read == written,
2741                    _ => true,
2742                }
2743        })
2744    })
2745}
2746
2747/// What qk adds to `task`'s key on its own, whatever its inputs declare.
2748fn infer(snapshot: &Snapshot, workspace: &Workspace, task: &Task) -> Result<Inferred> {
2749    let readable = snapshot
2750        .installed(&workspace.root, &task.id)?
2751        .is_some_and(|installed| installed.lockfile.is_some());
2752    let exists = |path: &str| {
2753        workspace.root.join(path).is_file() || snapshot.extra_candidates.contains(path)
2754    };
2755    let mut inferred = Inferred {
2756        lockfile_readable: readable,
2757        ..Inferred::default()
2758    };
2759    // Workspace resolution and configuration. Dotenv files are not keyed, as
2760    // in Nx: they hold per-machine values and credentials, and a task's `env`
2761    // inputs key the variables it declares. The root tsconfig, as Nx hashes it
2762    // into every task. The root package.json is not, as in Nx: what the root
2763    // importer installs counts for every task through the lockfile, and the
2764    // rest of it is the root project's manifest.
2765    inferred.tsconfig = crate::inputs::ROOT_TSCONFIG_FILES
2766        .into_iter()
2767        .find(|name| exists(name))
2768        .map(str::to_owned);
2769    // nx.json counts through what it changes: through the task's resolved
2770    // configuration, and by the settings that reach tasks otherwise. A tool
2771    // reading it itself declares it as an input, which keys the file whole.
2772    for path in ["nx.json", qk_config::LOCAL_WORKSPACE] {
2773        if exists(path) {
2774            inferred.settings.insert(path.into());
2775        }
2776    }
2777    if let Some(extended) = &workspace.extended {
2778        inferred.settings.insert(extended.clone());
2779    }
2780    for path in [
2781        ".gitignore",
2782        ".nxignore",
2783        "package-lock.json",
2784        "yarn.lock",
2785        "bun.lock",
2786    ] {
2787        if exists(path) {
2788            inferred.files.insert(path.into());
2789        }
2790    }
2791    // A pnpm lockfile qk can read is keyed by what the task's projects
2792    // install, and the workspace file without the resolution keys whose effect
2793    // lands in it.
2794    if exists("pnpm-workspace.yaml") {
2795        if readable {
2796            inferred.workspace_file = true;
2797        } else {
2798            inferred.files.insert("pnpm-workspace.yaml".into());
2799        }
2800    }
2801    if exists("pnpm-lock.yaml") && !readable {
2802        inferred.files.insert("pnpm-lock.yaml".into());
2803    }
2804    // The task's own package.json counts whole, its package scripts and
2805    // dependency declarations with it. A dependency's package.json counts by what it
2806    // decides for dependents, such as where their imports of it resolve; its
2807    // installs count through the lockfile.
2808    let mut packages = BTreeSet::from([task.project.clone()]);
2809    let mut pending = packages.clone();
2810    while let Some(project) = pending.pop_first() {
2811        for edge in &snapshot.projects.dependencies[&project] {
2812            if packages.insert(edge.target.clone()) {
2813                pending.insert(edge.target.clone());
2814            }
2815        }
2816    }
2817    for project in &packages {
2818        let own = *project == task.project;
2819        for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
2820            if !own && name != "package.json" {
2821                continue;
2822            }
2823            let path = Path::new(&workspace.projects[project].root).join(name);
2824            let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
2825            if !workspace.root.join(&path).is_file() {
2826                continue;
2827            }
2828            let path = path
2829                .to_str()
2830                .context("project path must be UTF-8")?
2831                .replace('\\', "/");
2832            // Its project.json and project.local.json are not added: what
2833            // they decide for the task counts through its resolved
2834            // configuration, and an input selecting them keys them as it
2835            // says.
2836            if own && name != "package.json" {
2837                inferred.project.insert(path);
2838                continue;
2839            }
2840            if own {
2841                inferred.files.insert(path);
2842                continue;
2843            }
2844            let text = std::fs::read_to_string(workspace.root.join(&path))
2845                .with_context(|| format!("cannot read {path}"))?;
2846            match manifest_for_dependents(&text, readable) {
2847                Some(value) => {
2848                    inferred.manifests.insert(path, value);
2849                }
2850                None => {
2851                    inferred.files.insert(path);
2852                }
2853            }
2854        }
2855    }
2856    if readable {
2857        // The root importer's packages resolve from every package in the
2858        // workspace, so they count for every task.
2859        inferred.lockfile = Some(
2860            std::iter::once(".".to_owned())
2861                .chain(
2862                    packages
2863                        .iter()
2864                        .map(|project| workspace.projects[project].root.clone()),
2865                )
2866                .collect(),
2867        );
2868    }
2869    Ok(inferred)
2870}
2871
2872/// `declared` and `inferred` together. A file both select is left to the
2873/// declared inputs, which count all of it.
2874fn join(declared: Declared, mut inferred: Inferred) -> Resolved {
2875    let mut covered = BTreeSet::new();
2876    inferred.files.retain(|path| {
2877        let keep = !declared.files.contains(path);
2878        if !keep {
2879            covered.insert(path.clone());
2880        }
2881        keep
2882    });
2883    inferred.manifests.retain(|path, _| {
2884        let keep = !declared.files.contains(path);
2885        if !keep {
2886            covered.insert(path.clone());
2887        }
2888        keep
2889    });
2890    if inferred.lockfile.is_some() && declared.files.contains("pnpm-lock.yaml") {
2891        inferred.lockfile = None;
2892        covered.insert("pnpm-lock.yaml".into());
2893    }
2894    if inferred.workspace_file && declared.files.contains("pnpm-workspace.yaml") {
2895        inferred.workspace_file = false;
2896        covered.insert("pnpm-workspace.yaml".into());
2897    }
2898    if let Some(tsconfig) = inferred
2899        .tsconfig
2900        .take_if(|path| declared.files.contains(path))
2901    {
2902        covered.insert(tsconfig);
2903    }
2904    inferred.covered = covered;
2905    Resolved { declared, inferred }
2906}
2907
2908/// What a task's key is computed from, as JSON: kept by the history so a
2909/// changed key can be explained.
2910pub fn inputs(
2911    snapshot: &Snapshot,
2912    workspace: &Workspace,
2913    task: &Task,
2914    prepared: &PreparedTask,
2915    dependencies: &BTreeMap<String, String>,
2916    cancelled: &AtomicBool,
2917) -> Result<Value> {
2918    let prefixes = snapshot.membership_prefixes(workspace, task)?;
2919    let candidates = snapshot.current_files_for(&workspace.root, Some(&prefixes), &task.id)?;
2920    let Resolved { declared, inferred } = resolve_candidates(
2921        snapshot,
2922        workspace,
2923        task,
2924        Some(prepared),
2925        cancelled,
2926        &candidates.files,
2927        &candidates.source_ignore,
2928    )?;
2929    let file_values = |paths: &BTreeSet<String>| {
2930        paths
2931            .iter()
2932            .map(|path| {
2933                let value = snapshot
2934                    .file_value(&workspace.root, path)
2935                    .with_context(|| format!("cannot read input {path}"))?;
2936                Ok((path.clone(), value))
2937            })
2938            .collect::<Result<BTreeMap<_, _>>>()
2939    };
2940    let mut files = file_values(&(&declared.files - &declared.ignored))?;
2941    for path in &declared.ignored {
2942        if let Some(value) = snapshot
2943            .ignored_value(&workspace.root, path)
2944            .with_context(|| format!("cannot read input {path}"))?
2945        {
2946            files.insert(path.clone(), value);
2947        }
2948    }
2949    let mut values = declared.values;
2950    // Left out without a declared environment, so other keys stay as they
2951    // were.
2952    if let Some(environment) = &prepared.environment {
2953        values.insert("environment".into(), environment.keyed());
2954    }
2955    let mut configuration = json!({
2956        "files": file_values(&inferred.files)?,
2957        "settings": workspace.config.keyed_settings(),
2958        "project": task_configuration(workspace, task),
2959    });
2960    let installed = snapshot.installed(&workspace.root, &task.id)?;
2961    if let (Some(importers), Some(installed)) = (&inferred.lockfile, &installed)
2962        && let Some(lockfile) = &installed.lockfile
2963    {
2964        let importers: BTreeMap<_, _> = importers
2965            .iter()
2966            .filter(|importer| lockfile.has_importer(importer))
2967            .map(|importer| {
2968                let digest = installed.digest(&format!("importer:{importer}"), || {
2969                    lockfile.installed(importer).unwrap_or_default()
2970                });
2971                (importer.clone(), digest)
2972            })
2973            .collect();
2974        configuration["lockfile"] = json!({
2975            "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
2976            "importers": importers,
2977        });
2978        // A lockfile keyed whole already covers the packages it installs.
2979        if !declared.external.is_empty() {
2980            let external: BTreeMap<_, _> = declared
2981                .external
2982                .iter()
2983                .map(|name| {
2984                    let digest =
2985                        installed.digest(&format!("package:{name}"), || lockfile.package(name));
2986                    // As in Nx, a package nothing installs is an error.
2987                    if digest == blake3::hash(b"").to_hex().as_str() {
2988                        bail!(
2989                            "the externalDependency {name:?} of {} could not be found",
2990                            task.id
2991                        );
2992                    }
2993                    Ok((name.clone(), digest))
2994                })
2995                .collect::<Result<_>>()?;
2996            values.insert("externalDependencies".into(), json!(external));
2997        }
2998    }
2999    if inferred.workspace_file {
3000        configuration["workspaceFile"] = match workspace_without_resolution(&workspace.root) {
3001            Some(value) => value,
3002            None => snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
3003        };
3004    }
3005    if !inferred.manifests.is_empty() {
3006        configuration["manifests"] = json!(inferred.manifests);
3007    }
3008    if let Some(path) = &inferred.tsconfig {
3009        let parsed = std::fs::read_to_string(workspace.root.join(path))
3010            .ok()
3011            .and_then(|text| TsConfig::parse(&text));
3012        configuration["tsconfig"] = match parsed {
3013            Some(tsconfig) if crate::inputs::selectively_hash_tsconfig(workspace) => json!({
3014                "rest": tsconfig.rest,
3015                "paths": tsconfig.paths_of(&declared.projects, |target| snapshot.owner(target)),
3016            }),
3017            Some(tsconfig) => json!({ "rest": tsconfig.rest }),
3018            None => snapshot.file_value(&workspace.root, path)?,
3019        };
3020    }
3021    let definition = keyed_definition(task);
3022    let hash = json!({
3023        "schema":"qk-local-v4", "qk":env!("CARGO_PKG_VERSION"),
3024        "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
3025        "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
3026        "files":files, "values":values, "inferred":configuration, "dependencies":dependencies,
3027    });
3028    Ok(hash)
3029}
3030
3031/// A task's definition as its key holds it. Warm state configuration never
3032/// changes a result, and as in Nx a target's metadata, such as the run
3033/// command shown for a script, is description only.
3034pub fn keyed_definition(task: &Task) -> qk_config::Target {
3035    let mut definition = task.definition.clone();
3036    definition.extra.remove("qk:warm");
3037    definition.extra.remove("qk:threads");
3038    definition.extra.remove("metadata");
3039    // What a recording keys is the recording's own concern.
3040    definition.extra.remove("qk:ultracache");
3041    definition
3042}
3043
3044/// What a task's key holds of its project's configuration beside its
3045/// definition and resolved inputs: the project's keyed settings, such as its
3046/// root, the paths its outputs resolve to, which `{project.*}` tokens can
3047/// read from anywhere in the configuration, and all of the configuration
3048/// when a forwarded argument holds such a token, as the task reads it then.
3049pub fn task_configuration(workspace: &Workspace, task: &Task) -> Value {
3050    let project = &workspace.projects[&task.project];
3051    let mut value = json!({ "settings": project.keyed_settings() });
3052    if let Ok((outputs, _)) = paths::resolved_outputs(workspace, task) {
3053        value["outputs"] = json!(outputs);
3054    }
3055    if task.args.iter().any(|arg| arg.contains("{project")) {
3056        value["configuration"] = serde_json::to_value(project).unwrap_or_default();
3057    }
3058    value
3059}
3060
3061/// The key for a task's inputs. Objects are keyed with their fields sorted,
3062/// so the order they were written in, which configuration keeps, does not
3063/// change the key.
3064pub fn key(inputs: &Value) -> Result<String> {
3065    Ok(blake3::hash(&serde_json::to_vec(&sorted(inputs))?)
3066        .to_hex()
3067        .to_string())
3068}
3069
3070/// `value` with every object's fields in key order.
3071fn sorted(value: &Value) -> Value {
3072    match value {
3073        Value::Object(object) => {
3074            let mut fields: Vec<_> = object.iter().collect();
3075            fields.sort_unstable_by_key(|(name, _)| *name);
3076            Value::Object(
3077                fields
3078                    .into_iter()
3079                    .map(|(name, value)| (name.clone(), sorted(value)))
3080                    .collect(),
3081            )
3082        }
3083        Value::Array(items) => Value::Array(items.iter().map(sorted).collect()),
3084        other => other.clone(),
3085    }
3086}
3087
3088pub fn fingerprint(
3089    snapshot: &Snapshot,
3090    workspace: &Workspace,
3091    task: &Task,
3092    prepared: &PreparedTask,
3093    dependencies: &BTreeMap<String, String>,
3094    cancelled: &AtomicBool,
3095) -> Result<String> {
3096    key(&inputs(
3097        snapshot,
3098        workspace,
3099        task,
3100        prepared,
3101        dependencies,
3102        cancelled,
3103    )?)
3104}
3105
3106/// The key of a task keyed by its recordings, before its path set: `inputs`,
3107/// the task's key, without its declared file inputs, which a path set
3108/// replaces, but with its `"always": true` inputs, which count whatever it
3109/// read, and with what `ignoreReads` leaves out of its path sets and
3110/// `produced`, what decides which paths in `node_modules` they keep.
3111#[allow(clippy::too_many_arguments)]
3112pub(crate) fn weak_key(
3113    snapshot: &Snapshot,
3114    workspace: &Workspace,
3115    graph: &TaskGraph,
3116    task: &Task,
3117    prepared: &PreparedTask,
3118    inputs: &Value,
3119    ignore_reads: &[String],
3120    ignored_within: &[(&str, bool)],
3121    produced: Option<Value>,
3122    cancelled: &AtomicBool,
3123) -> Result<String> {
3124    let mut weak = inputs.clone();
3125    let object = weak
3126        .as_object_mut()
3127        .context("a key's inputs are an object")?;
3128    object.remove("files");
3129    object.remove("dependencies");
3130    if let Some(Value::Object(values)) = object.get_mut("values") {
3131        values.retain(|name, _| {
3132            !name.starts_with("json:") && !name.starts_with("dependentTasksOutputFiles:")
3133        });
3134    }
3135    let mut always = task.clone();
3136    always.definition.inputs = Some(
3137        declared_inputs(task)
3138            .into_iter()
3139            .filter(|input| input.get("always") == Some(&Value::Bool(true)))
3140            .collect(),
3141    );
3142    let dependencies = dependency_keys(snapshot, workspace, graph, &always, cancelled)?;
3143    let always = self::inputs(
3144        snapshot,
3145        workspace,
3146        &always,
3147        prepared,
3148        &dependencies,
3149        cancelled,
3150    )?;
3151    object.insert(
3152        "always".into(),
3153        json!({"files": always["files"], "values": always["values"], "dependencies": always["dependencies"]}),
3154    );
3155    // v2: path sets keep what upstream tasks write into `node_modules`. One
3156    // recorded before left it out, and could key a task whose generated
3157    // input had since changed.
3158    object.insert("schema".into(), json!("qk-weak-v2"));
3159    object.insert("ignoreReads".into(), json!(ignore_reads));
3160    // What its `^` entries match moves with the project graph, the projects
3161    // depended on and those nested in them that are not, so a path set
3162    // recorded under one never keys a task under another. Left out without
3163    // them, so other weak keys stay as they were.
3164    if !ignored_within.is_empty() {
3165        object.insert("ignoredWithin".into(), json!(ignored_within));
3166    }
3167    // Which paths in `node_modules` its path sets keep moves with what the
3168    // tasks it depends on declare as outputs, and its `includeIgnored`
3169    // inputs: a path set recorded under others could leave out a file
3170    // generated there, and so key nothing when it changes.
3171    if let Some(produced) = produced {
3172        object.insert("produced".into(), produced);
3173    }
3174    key(&weak)
3175}
3176
3177/// The task `id` of `graph`, run or left out by `exclude_task_dependencies`.
3178fn dependency_task<'a>(graph: &'a TaskGraph, id: &str) -> Result<&'a qk_taskgraph::Task> {
3179    graph
3180        .keyed_task(id)
3181        .with_context(|| format!("task {id} is not in the task graph"))
3182}
3183
3184/// The dependency outputs a task's `dependentTasksOutputFiles` inputs read,
3185/// as Nx hashes them: the files each glob selects of the declared outputs of
3186/// its direct dependencies, or with `transitive` of every task it depends on.
3187/// Nothing else about a dependency keys a task.
3188pub(crate) fn dependency_keys(
3189    snapshot: &Snapshot,
3190    workspace: &Workspace,
3191    graph: &TaskGraph,
3192    task: &Task,
3193    cancelled: &AtomicBool,
3194) -> Result<BTreeMap<String, String>> {
3195    dependency_keys_impl(snapshot, workspace, graph, task, cancelled, false)
3196}
3197
3198/// Read dependency artifacts again before accepting an execution as unchanged.
3199pub(crate) fn recheck_dependency_keys(
3200    snapshot: &Snapshot,
3201    workspace: &Workspace,
3202    graph: &TaskGraph,
3203    task: &Task,
3204    cancelled: &AtomicBool,
3205) -> Result<BTreeMap<String, String>> {
3206    dependency_keys_impl(snapshot, workspace, graph, task, cancelled, true)
3207}
3208
3209fn dependency_keys_impl(
3210    snapshot: &Snapshot,
3211    workspace: &Workspace,
3212    graph: &TaskGraph,
3213    task: &Task,
3214    cancelled: &AtomicBool,
3215    fresh: bool,
3216) -> Result<BTreeMap<String, String>> {
3217    let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
3218    let selections: Vec<_> = resolved.dependency_output_inputs().collect();
3219    let mut keys = BTreeMap::new();
3220    let mut fresh_outputs = HashMap::new();
3221    for (pattern, transitive) in selections {
3222        let matcher = snapshot.pattern(pattern, false)?;
3223        let selected = graph.output_dependencies(task, transitive);
3224        for id in selected {
3225            // As in Nx, a dependency without declared outputs offers none.
3226            if !Outputs::new(workspace, dependency_task(graph, &id)?)?.declared() {
3227                continue;
3228            }
3229            let outputs =
3230                if fresh {
3231                    if !fresh_outputs.contains_key(&id) {
3232                        fresh_outputs.insert(
3233                            id.clone(),
3234                            Arc::new(snapshot.fresh_dependency_outputs(
3235                                workspace,
3236                                dependency_task(graph, &id)?,
3237                            )?),
3238                        );
3239                    }
3240                    fresh_outputs[&id].clone()
3241                } else {
3242                    snapshot.dependency_outputs(workspace, dependency_task(graph, &id)?)?
3243                };
3244            let mut files = BTreeMap::new();
3245            for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
3246                let known = outputs.values.lock().unwrap().get(path).cloned();
3247                let value = match known {
3248                    Some(value) => value,
3249                    None => {
3250                        let value = snapshot.file_value(&workspace.root, path)?;
3251                        // Links depend on paths outside the declared output roots.
3252                        if value.get("link").is_none() {
3253                            outputs
3254                                .values
3255                                .lock()
3256                                .unwrap()
3257                                .insert(path.clone(), value.clone());
3258                        }
3259                        value
3260                    }
3261                };
3262                files.insert(path.clone(), value);
3263            }
3264            keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
3265        }
3266    }
3267    Ok(keys)
3268}
3269
3270/// What one task's `dependentTasksOutputFiles` glob selects of the outputs it
3271/// reads.
3272#[derive(Clone, Debug, PartialEq, Eq)]
3273pub struct DependencyGlob {
3274    pub task: String,
3275    pub pattern: String,
3276    pub selects: Selects,
3277}
3278
3279#[derive(Clone, Debug, PartialEq, Eq)]
3280pub enum Selects {
3281    /// None of the tasks it reads declares outputs, so nothing ever.
3282    NoDeclaredOutputs,
3283    /// Some of their outputs are not built, so it cannot be told.
3284    NotBuilt,
3285    /// None of the files their outputs hold now, this many.
3286    NoneOf(usize),
3287    /// Some of the files their outputs hold now.
3288    Some,
3289}
3290
3291/// What each `dependentTasksOutputFiles` glob of `graph`'s tasks selects of
3292/// its dependencies' outputs as they are on disk, without running anything.
3293pub fn dependency_globs(workspace: &Workspace, graph: &TaskGraph) -> Result<Vec<DependencyGlob>> {
3294    let snapshot = Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
3295    let cancelled = AtomicBool::new(false);
3296    let mut globs = Vec::new();
3297    for task in graph.tasks.values() {
3298        // A task whose inputs cannot be resolved fails as it runs instead.
3299        let Ok(resolved) = resolve(&snapshot, workspace, task, None, &cancelled) else {
3300            continue;
3301        };
3302        for (pattern, transitive) in resolved.dependency_output_inputs() {
3303            let selected = graph.output_dependencies(task, transitive);
3304            let matcher = snapshot.pattern(pattern, false)?;
3305            // A dependency not built yet may still produce what it matches.
3306            let (mut declared, mut files, mut matched, mut unbuilt) = (false, 0, false, false);
3307            for id in &selected {
3308                let dependency = dependency_task(graph, id)?;
3309                if !Outputs::new(workspace, dependency)?.declared() {
3310                    continue;
3311                }
3312                declared = true;
3313                let outputs = snapshot.dependency_outputs(workspace, dependency)?;
3314                unbuilt |= outputs.files.is_empty();
3315                files += outputs.files.len();
3316                matched |= outputs.files.iter().any(|path| matcher.is_match(path));
3317            }
3318            globs.push(DependencyGlob {
3319                task: task.id.clone(),
3320                pattern: pattern.to_owned(),
3321                selects: match (declared, matched, unbuilt) {
3322                    (false, ..) => Selects::NoDeclaredOutputs,
3323                    (_, true, _) => Selects::Some,
3324                    (_, _, true) => Selects::NotBuilt,
3325                    _ => Selects::NoneOf(files),
3326                },
3327            });
3328        }
3329    }
3330    Ok(globs)
3331}
3332
3333#[cfg(test)]
3334mod tests {
3335
3336    /// Every file keyed by its whole content, declared or inferred.
3337    fn whole(resolved: &Resolved) -> BTreeSet<String> {
3338        resolved.whole_files().map(str::to_owned).collect()
3339    }
3340    use super::*;
3341    use qk_taskgraph::Request;
3342
3343    fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
3344        let root = tempfile::tempdir().unwrap();
3345        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
3346        let mut post = json!({"command": "true"});
3347        if let Some(outputs) = outputs {
3348            post["outputs"] = outputs;
3349        }
3350        std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
3351            "gen": {"command":"true", "outputs":["{projectRoot}/generated"]},
3352            "post": post,
3353            "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
3354        }}).to_string()).unwrap();
3355        std::fs::create_dir(root.path().join("generated")).unwrap();
3356        std::fs::write(root.path().join("generated/value"), "one").unwrap();
3357        let workspace = Workspace::load(root.path()).unwrap();
3358        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
3359        (root, workspace, graph)
3360    }
3361
3362    /// A `json` glob selects a dependency's declared outputs, keyed by their
3363    /// fields, and makes that dependency a producer, as a fileset does.
3364
3365    #[test]
3366    fn keys_ignore_the_order_object_fields_were_written_in() {
3367        let written = json!({"options": {"cwd": "app", "command": "x"}, "args": ["a", "b"]});
3368        let reordered = json!({"args": ["a", "b"], "options": {"command": "x", "cwd": "app"}});
3369        assert_eq!(key(&written).unwrap(), key(&reordered).unwrap());
3370        let swapped = json!({"options": {"cwd": "app", "command": "x"}, "args": ["b", "a"]});
3371        assert_ne!(key(&written).unwrap(), key(&swapped).unwrap());
3372    }
3373    #[test]
3374    fn json_globs_select_dependency_outputs() {
3375        let root = tempfile::tempdir().unwrap();
3376        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
3377        for (path, content) in [
3378            (
3379                "apps/app/project.json",
3380                json!({"name": "app", "implicitDependencies": ["lib"], "targets": {
3381                    "build": {"command": "true", "dependsOn": ["^build"], "inputs": [
3382                        {"json": "{workspaceRoot}/libs/lib/out/*.json", "fields": ["version"]}
3383                    ]}
3384                }}),
3385            ),
3386            (
3387                "libs/lib/project.json",
3388                json!({"name": "lib", "targets": {
3389                    "build": {"command": "true", "outputs": ["{projectRoot}/out"]}
3390                }}),
3391            ),
3392            (
3393                "libs/lib/out/meta.json",
3394                json!({"version": 2, "built": "now"}),
3395            ),
3396        ] {
3397            let path = root.path().join(path);
3398            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3399            std::fs::write(path, content.to_string()).unwrap();
3400        }
3401        let workspace = Workspace::load(root.path()).unwrap();
3402        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
3403        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3404        let resolved = resolve(
3405            &snapshot,
3406            &workspace,
3407            &graph.tasks["app:build"],
3408            None,
3409            &AtomicBool::new(false),
3410        )
3411        .unwrap();
3412        assert_eq!(
3413            resolved.declared.values.get("json:libs/lib/out/meta.json"),
3414            Some(&json!({"version": 2}))
3415        );
3416        assert_eq!(
3417            resolved.declared.producers,
3418            BTreeSet::from(["lib:build".to_owned()])
3419        );
3420    }
3421
3422    /// The declared key covers a recording when every file it read is keyed,
3423    /// every file it looked for would be selected and not gitignored, and
3424    /// every entry a directory it listed could hold would be selected.
3425    #[test]
3426    fn declared_inputs_cover_what_they_would_select_and_not_what_they_leave_out() {
3427        let root = tempfile::tempdir().unwrap();
3428        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
3429        std::fs::write(
3430            root.path().join("project.json"),
3431            json!({"name": "app", "targets": {
3432                "build": {"command": "true", "inputs": [
3433                    "{projectRoot}/src/**/*", "!{projectRoot}/src/skip/**/*"
3434                ]},
3435                "gen": {"command": "true", "outputs": ["{projectRoot}/src/gen"]}
3436            }})
3437            .to_string(),
3438        )
3439        .unwrap();
3440        std::fs::write(root.path().join(".gitignore"), "src/secret.ts\n").unwrap();
3441        for path in [
3442            "src/a.ts",
3443            "src/lib/b.ts",
3444            "src/skip/c.ts",
3445            "src/gen/d.ts",
3446            "README.md",
3447        ] {
3448            let path = root.path().join(path);
3449            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3450            std::fs::write(path, "x").unwrap();
3451        }
3452        let workspace = Workspace::load(root.path()).unwrap();
3453        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
3454        let task = &graph.tasks["app:build"];
3455        let prepared = qk_executor::prepare(&workspace, task, &BTreeMap::new()).unwrap();
3456        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3457        let coverage = coverage(
3458            &snapshot,
3459            &workspace,
3460            &graph,
3461            task,
3462            &prepared,
3463            &AtomicBool::new(false),
3464        )
3465        .unwrap();
3466        let set = |part: &str, path: &str| {
3467            let mut set = crate::recorded::PathSet::default();
3468            match part {
3469                "read" => &mut set.reads,
3470                "present" => &mut set.present,
3471                "absent" => &mut set.absent,
3472                _ => &mut set.listings,
3473            }
3474            .insert(path.to_owned());
3475            set
3476        };
3477        for (part, path, covered) in [
3478            ("read", "src/a.ts", true),
3479            ("read", "README.md", false),
3480            ("read", "src/skip/c.ts", false),
3481            ("present", "src/lib", true),
3482            ("absent", "src/new.ts", true),
3483            // Excluded, or gitignored: creating it would not change the key.
3484            ("absent", "src/skip/new.ts", false),
3485            ("absent", "src/secret.ts", false),
3486            ("absent", ".env.local", false),
3487            // Another target's output, which no declared key holds.
3488            ("absent", "src/gen/e.ts", false),
3489            ("listing", "src/gen", false),
3490            ("listing", "src/lib", true),
3491            // An exclusion reaches into it, or nothing selects all of it.
3492            ("listing", "src", false),
3493            ("listing", ".", false),
3494        ] {
3495            let generated = |path: &str| path == "src/gen" || path.starts_with("src/gen/");
3496            assert_eq!(
3497                coverage.covers(&snapshot, root.path(), &set(part, path), &generated),
3498                covered,
3499                "{part} {path}"
3500            );
3501        }
3502    }
3503
3504    #[test]
3505    fn execution_fingerprints_refresh_sources_and_preserve_artifact_exclusions() {
3506        let (root, mut workspace, _) = fixture(Some(json!(["{projectRoot}/dist"])));
3507        let definition = workspace
3508            .projects
3509            .get_mut("app")
3510            .unwrap()
3511            .targets
3512            .get_mut("post")
3513            .unwrap();
3514        definition
3515            .extra
3516            .insert("qk:warm".into(), json!({"paths": ["scratch"]}));
3517        std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
3518        std::fs::create_dir(root.path().join("src")).unwrap();
3519        std::fs::write(root.path().join("src/original"), "one").unwrap();
3520        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
3521        let task = &graph.tasks["app:post"];
3522        let prepared = qk_executor::prepare(&workspace, task, &BTreeMap::new()).unwrap();
3523        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3524        let inputs = || {
3525            super::inputs(
3526                &snapshot,
3527                &workspace,
3528                task,
3529                &prepared,
3530                &BTreeMap::new(),
3531                &AtomicBool::new(false),
3532            )
3533            .unwrap()
3534        };
3535        let before = inputs();
3536        // Artifacts created after startup must not change the execution key.
3537        for directory in ["generated", "dist", "scratch", "cache", "ignored"] {
3538            std::fs::create_dir_all(root.path().join(directory)).unwrap();
3539            std::fs::write(root.path().join(directory).join("new"), "artifact").unwrap();
3540        }
3541        assert_eq!(before, inputs());
3542        // This covers both post-execution verification and a later task's first
3543        // fingerprint against a snapshot created before this source existed.
3544        std::fs::write(root.path().join("src/added"), "two").unwrap();
3545        let after = inputs();
3546        assert!(after["files"].get("src/added").is_some());
3547        assert_ne!(key(&before).unwrap(), key(&after).unwrap());
3548        std::fs::remove_file(root.path().join("src/added")).unwrap();
3549        assert_eq!(before, inputs());
3550        std::fs::remove_file(root.path().join("src/original")).unwrap();
3551        assert!(inputs()["files"].get("src/original").is_none());
3552    }
3553
3554    #[test]
3555    fn membership_reuses_settled_candidates_and_detects_directory_changes() {
3556        let mut fixtures = Vec::new();
3557        for git in [false, true] {
3558            let (root, workspace, graph) = fixture(Some(json!(["{projectRoot}/dist"])));
3559            for directory in ["src/empty", "dist", "cache", "ignored"] {
3560                std::fs::create_dir_all(root.path().join(directory)).unwrap();
3561            }
3562            std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
3563            std::fs::write(root.path().join("src/original"), "one").unwrap();
3564            if git {
3565                assert!(
3566                    paths::git(root.path(), &["init", "-q"])
3567                        .unwrap()
3568                        .status
3569                        .success()
3570                );
3571                assert!(
3572                    paths::git(root.path(), &["add", "."])
3573                        .unwrap()
3574                        .status
3575                        .success()
3576                );
3577            }
3578            let snapshot =
3579                Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3580            snapshot.current_files(root.path()).unwrap();
3581            fixtures.push((root, snapshot));
3582        }
3583        // Recent directory metadata deliberately uses the conservative listing
3584        // path. Waiting lets this test exercise the actual reuse path too.
3585        std::thread::sleep(Duration::from_millis(2100));
3586        for (root, snapshot) in fixtures {
3587            let files = || snapshot.current_files(root.path()).unwrap();
3588            let before = files();
3589            assert!(Arc::ptr_eq(&before, &files()));
3590            for directory in ["generated", "dist", "cache"] {
3591                std::fs::write(root.path().join(directory).join("new"), "artifact").unwrap();
3592            }
3593            assert!(Arc::ptr_eq(&before, &files()));
3594            std::fs::write(root.path().join("src/empty/added"), "two").unwrap();
3595            assert!(files().contains("src/empty/added"));
3596            std::fs::create_dir_all(root.path().join("src/new/nested")).unwrap();
3597            std::fs::write(root.path().join("src/new/nested/added"), "three").unwrap();
3598            assert!(files().contains("src/new/nested/added"));
3599            std::fs::rename(
3600                root.path().join("src/original"),
3601                root.path().join("src/renamed"),
3602            )
3603            .unwrap();
3604            let renamed = files();
3605            assert!(!renamed.contains("src/original"));
3606            assert!(renamed.contains("src/renamed"));
3607            std::fs::remove_file(root.path().join("src/renamed")).unwrap();
3608            assert!(!files().contains("src/renamed"));
3609            // Policy files must be checked even when directory entries do not
3610            // change (editing an existing ignore file in place).
3611            std::fs::write(root.path().join(".gitignore"), "ignored/\nsrc/empty/\n").unwrap();
3612            assert!(!files().contains("src/empty/added"));
3613            std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
3614            assert!(files().contains("src/empty/added"));
3615        }
3616    }
3617
3618    #[test]
3619    fn identical_ignore_rewrites_reuse_candidates_and_changed_rules_refresh() {
3620        for name in [".gitignore", ".nxignore"] {
3621            let (root, workspace, graph) = fixture(None);
3622            let policy = root.path().join(name);
3623            std::fs::write(root.path().join("alpha"), "a").unwrap();
3624            std::fs::write(root.path().join("bravo"), "b").unwrap();
3625            std::fs::write(&policy, "alpha\n").unwrap();
3626            let snapshot =
3627                Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3628            let original = snapshot.current_files(root.path()).unwrap();
3629            assert!(!original.contains("alpha"));
3630            assert!(original.contains("bravo"));
3631            for _ in 0..3 {
3632                std::fs::write(&policy, "alpha\n").unwrap();
3633                assert!(Arc::ptr_eq(
3634                    &original,
3635                    &snapshot.current_files(root.path()).unwrap()
3636                ));
3637            }
3638            // Same-length content can change while coarse timestamps appear
3639            // identical. Recent policies must still compare their contents.
3640            std::fs::write(&policy, "bravo\n").unwrap();
3641            {
3642                let mut cached = snapshot.membership.lock().unwrap();
3643                Arc::make_mut(cached.as_mut().unwrap())
3644                    .stamps
3645                    .insert(policy.clone(), membership_stamp(&policy).unwrap());
3646            }
3647            let changed = snapshot.current_files(root.path()).unwrap();
3648            assert!(!Arc::ptr_eq(&original, &changed));
3649            assert!(changed.contains("alpha"));
3650            assert!(!changed.contains("bravo"));
3651            std::fs::remove_file(&policy).unwrap();
3652            assert!(
3653                snapshot
3654                    .current_files(root.path())
3655                    .unwrap()
3656                    .contains("bravo")
3657            );
3658        }
3659    }
3660
3661    #[test]
3662    fn ignore_contents_changed_after_inventory_cannot_bless_a_listing() {
3663        let (root, workspace, graph) = fixture(None);
3664        let policy = root.path().join(".nxignore");
3665        std::fs::write(&policy, "alpha\n").unwrap();
3666        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3667        snapshot.current_files(root.path()).unwrap();
3668        let membership = snapshot.membership.lock().unwrap().clone().unwrap();
3669        std::fs::write(&policy, "bravo\n").unwrap();
3670        assert!(!membership.state(None).unwrap().0);
3671    }
3672
3673    #[test]
3674    fn a_directory_missed_by_the_inventory_cannot_enable_reuse() {
3675        let (root, workspace, graph) = fixture(None);
3676        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3677        snapshot.current_files(root.path()).unwrap();
3678        std::fs::create_dir(root.path().join("new-empty")).unwrap();
3679        let mut cached = snapshot.membership.lock().unwrap();
3680        let membership = Arc::make_mut(cached.as_mut().unwrap());
3681        // Model creation between the directory walk and child-name capture:
3682        // the new directory is in its parent's names but not in the inventory.
3683        membership
3684            .directory_entries
3685            .get_mut(root.path())
3686            .unwrap()
3687            .insert((OsString::from("new-empty"), 1));
3688        assert!(membership.entries(root.path()).unwrap().is_none());
3689        assert!(!membership.state(None).unwrap().0);
3690    }
3691
3692    #[test]
3693    fn a_policy_missed_by_discovery_cannot_enable_reuse() {
3694        let (root, workspace, graph) = fixture(None);
3695        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3696        snapshot.current_files(root.path()).unwrap();
3697        std::fs::write(root.path().join(".nxignore"), "hidden\n").unwrap();
3698        let mut cached = snapshot.membership.lock().unwrap();
3699        let membership = Arc::make_mut(cached.as_mut().unwrap());
3700        // Model a new policy appearing after discovery but before capturing
3701        // its parent's entries; its content would otherwise have no stamp.
3702        membership
3703            .directory_entries
3704            .get_mut(root.path())
3705            .unwrap()
3706            .insert((OsString::from(".nxignore"), 0));
3707        assert!(membership.entries(root.path()).unwrap().is_none());
3708        assert!(!membership.state(None).unwrap().0);
3709    }
3710
3711    #[test]
3712    fn output_publication_reuses_candidates_but_source_names_still_refresh() {
3713        for partial in [false, true] {
3714            let outputs = if partial {
3715                json!(["{projectRoot}/dist", "!{projectRoot}/dist/keep"])
3716            } else {
3717                json!(["{projectRoot}/dist"])
3718            };
3719            let (root, workspace, graph) = fixture(Some(outputs));
3720            std::fs::create_dir_all(root.path().join("src")).unwrap();
3721            std::fs::write(root.path().join("src/original"), "one").unwrap();
3722            if partial {
3723                std::fs::create_dir_all(root.path().join("dist/keep")).unwrap();
3724            }
3725            let snapshot =
3726                Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3727            snapshot.current_files(root.path()).unwrap();
3728            // Policies retain their conservative timestamp window. Directory
3729            // names are freshly verified throughout the window instead.
3730            std::thread::sleep(Duration::from_millis(2100));
3731            let before = snapshot.current_files(root.path()).unwrap();
3732            std::fs::create_dir_all(root.path().join("dist")).unwrap();
3733            std::fs::write(root.path().join("dist/restored"), "artifact").unwrap();
3734            let restored = snapshot.current_files(root.path()).unwrap();
3735            assert!(Arc::ptr_eq(&before, &restored));
3736            std::fs::remove_file(root.path().join("dist/restored")).unwrap();
3737            assert!(Arc::ptr_eq(
3738                &before,
3739                &snapshot.current_files(root.path()).unwrap()
3740            ));
3741            let source = if partial { "dist/keep/new" } else { "src/new" };
3742            std::fs::write(root.path().join(source), "source").unwrap();
3743            let after = snapshot.current_files(root.path()).unwrap();
3744            assert!(after.contains(source));
3745            assert!(!Arc::ptr_eq(&before, &after));
3746            std::fs::remove_file(root.path().join(source)).unwrap();
3747            assert!(
3748                !snapshot
3749                    .current_files(root.path())
3750                    .unwrap()
3751                    .contains(source)
3752            );
3753        }
3754    }
3755
3756    #[test]
3757    fn concurrent_fingerprints_refresh_without_replacing_newer_membership() {
3758        let (root, workspace, graph) = fixture(None);
3759        std::fs::create_dir_all(root.path().join("src")).unwrap();
3760        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3761        let original = snapshot.current_files(root.path()).unwrap();
3762        let barrier = std::sync::Barrier::new(9);
3763        std::thread::scope(|scope| {
3764            let workers: Vec<_> = (0..8)
3765                .map(|_| {
3766                    scope.spawn(|| {
3767                        let mut rounds = Vec::new();
3768                        for _ in 0..6 {
3769                            barrier.wait();
3770                            rounds.push(snapshot.current_files(root.path()));
3771                            barrier.wait();
3772                        }
3773                        rounds
3774                    })
3775                })
3776                .collect();
3777            for round in 0..6 {
3778                std::fs::write(root.path().join(format!("src/new{round}")), "source").unwrap();
3779                barrier.wait();
3780                barrier.wait();
3781            }
3782            // Assertions run after synchronization, so a failed membership
3783            // check cannot strand other workers at a barrier.
3784            for worker in workers {
3785                for (round, files) in worker.join().unwrap().into_iter().enumerate() {
3786                    let files = files.unwrap();
3787                    for added in 0..=round {
3788                        assert!(files.contains(&format!("src/new{added}")));
3789                    }
3790                }
3791            }
3792        });
3793        assert!(!original.contains("src/new0"));
3794        assert!(
3795            snapshot
3796                .current_files(root.path())
3797                .unwrap()
3798                .contains("src/new5")
3799        );
3800    }
3801
3802    #[test]
3803    fn retained_candidates_keep_their_dependency_output_ignore_policy() {
3804        let (root, mut workspace, _) = fixture(None);
3805        workspace
3806            .projects
3807            .get_mut("app")
3808            .unwrap()
3809            .targets
3810            .get_mut("build")
3811            .unwrap()
3812            .inputs = Some(vec![json!("{workspaceRoot}/**/*.txt")]);
3813        std::fs::create_dir(root.path().join("src")).unwrap();
3814        for name in ["alpha", "beta", "gamma"] {
3815            for directory in ["src", "generated"] {
3816                std::fs::write(root.path().join(format!("{directory}/{name}.txt")), name).unwrap();
3817            }
3818        }
3819        let policy = |keep: &str| {
3820            let ignored: String = ["alpha", "beta", "gamma"]
3821                .into_iter()
3822                .filter(|name| *name != keep)
3823                .flat_map(|name| {
3824                    [
3825                        format!("src/{name}.txt\n"),
3826                        format!("generated/{name}.txt\n"),
3827                    ]
3828                })
3829                .collect();
3830            std::fs::write(root.path().join(".nxignore"), ignored).unwrap();
3831        };
3832        policy("alpha");
3833        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
3834        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
3835        let first = snapshot
3836            .current_files_for(root.path(), None, "test")
3837            .unwrap();
3838        policy("beta");
3839        let second = snapshot
3840            .current_files_for(root.path(), None, "test")
3841            .unwrap();
3842        policy("gamma");
3843        let third = snapshot
3844            .current_files_for(root.path(), None, "test")
3845            .unwrap();
3846        // Deterministically pause two fingerprints after candidate acquisition,
3847        // then let another fingerprint refresh policy twice before they resolve.
3848        let cancelled = AtomicBool::new(false);
3849        let expected = |name: &str| {
3850            BTreeSet::from([
3851                ".nxignore".into(),
3852                format!("src/{name}.txt"),
3853                format!("generated/{name}.txt"),
3854            ])
3855        };
3856        for (candidates, name) in [(first, "alpha"), (second, "beta"), (third, "gamma")] {
3857            let resolved = resolve_candidates(
3858                &snapshot,
3859                &workspace,
3860                &graph.tasks["app:build"],
3861                None,
3862                &cancelled,
3863                &candidates.files,
3864                &candidates.source_ignore,
3865            )
3866            .unwrap();
3867            assert_eq!(whole(&resolved), expected(name));
3868        }
3869        // Inspection still uses its original candidate list and original policy.
3870        let inspected = resolve(
3871            &snapshot,
3872            &workspace,
3873            &graph.tasks["app:build"],
3874            None,
3875            &cancelled,
3876        )
3877        .unwrap();
3878        assert_eq!(whole(&inspected), expected("alpha"));
3879    }
3880
3881    #[test]
3882    fn scoped_membership_defers_unrelated_changes_until_a_matching_task_asks() {
3883        let (root, mut workspace, _) = fixture(Some(json!(["{projectRoot}/dist"])));
3884        let targets = &mut workspace.projects.get_mut("app").unwrap().targets;
3885        targets.get_mut("post").unwrap().inputs =
3886            Some(vec![json!("{workspaceRoot}/left/**/*.txt")]);
3887        targets.get_mut("gen").unwrap().inputs =
3888            Some(vec![json!("{workspaceRoot}/right/**/*.txt")]);
3889        targets.get_mut("build").unwrap().inputs = Some(vec![json!("{workspaceRoot}/**/*.txt")]);
3890        for directory in ["left/empty", "right/empty"] {
3891            std::fs::create_dir_all(root.path().join(directory)).unwrap();
3892        }
3893        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
3894        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3895        snapshot.current_files(&workspace.root).unwrap();
3896        std::thread::sleep(Duration::from_millis(2100));
3897        let left = snapshot
3898            .membership_prefixes(&workspace, &graph.tasks["app:post"])
3899            .unwrap();
3900        let right = snapshot
3901            .membership_prefixes(&workspace, &graph.tasks["app:gen"])
3902            .unwrap();
3903        let all = snapshot
3904            .membership_prefixes(&workspace, &graph.tasks["app:build"])
3905            .unwrap();
3906        let before = snapshot
3907            .current_files_for(&workspace.root, Some(&left), "test")
3908            .unwrap();
3909        std::fs::write(root.path().join("right/empty/new.txt"), "right").unwrap();
3910        let unrelated = snapshot
3911            .current_files_for(&workspace.root, Some(&left), "test")
3912            .unwrap();
3913        assert!(Arc::ptr_eq(&before.files, &unrelated.files));
3914        assert!(!unrelated.files.contains("right/empty/new.txt"));
3915        assert!(
3916            snapshot
3917                .current_files_for(&workspace.root, Some(&right), "test")
3918                .unwrap()
3919                .files
3920                .contains("right/empty/new.txt")
3921        );
3922        std::fs::create_dir_all(root.path().join("left/empty/nested")).unwrap();
3923        std::fs::write(root.path().join("left/empty/nested/new.txt"), "left").unwrap();
3924        assert!(
3925            snapshot
3926                .current_files_for(&workspace.root, Some(&left), "test")
3927                .unwrap()
3928                .files
3929                .contains("left/empty/nested/new.txt")
3930        );
3931        std::fs::write(root.path().join("right/empty/another.txt"), "right").unwrap();
3932        assert!(
3933            snapshot
3934                .current_files_for(&workspace.root, Some(&all), "test")
3935                .unwrap()
3936                .files
3937                .contains("right/empty/another.txt")
3938        );
3939    }
3940
3941    #[test]
3942    fn membership_watches_the_directories_json_globs_select_from() {
3943        let (root, mut workspace, _) = fixture(Some(json!(["{projectRoot}/dist"])));
3944        let targets = &mut workspace.projects.get_mut("app").unwrap().targets;
3945        targets.get_mut("build").unwrap().inputs = Some(vec![json!(
3946            {"json": "{workspaceRoot}/config/**/*.json", "fields": ["version"]}
3947        )]);
3948        std::fs::create_dir_all(root.path().join("config/empty")).unwrap();
3949        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
3950        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3951        snapshot.current_files(&workspace.root).unwrap();
3952        std::thread::sleep(Duration::from_millis(2100));
3953        let prefixes = snapshot
3954            .membership_prefixes(&workspace, &graph.tasks["app:build"])
3955            .unwrap();
3956        snapshot
3957            .current_files_for(&workspace.root, Some(&prefixes), "test")
3958            .unwrap();
3959        std::fs::write(
3960            root.path().join("config/empty/new.json"),
3961            r#"{"version": 1}"#,
3962        )
3963        .unwrap();
3964        assert!(
3965            snapshot
3966                .current_files_for(&workspace.root, Some(&prefixes), "test")
3967                .unwrap()
3968                .files
3969                .contains("config/empty/new.json")
3970        );
3971    }
3972
3973    #[test]
3974    fn membership_watches_git_ignored_tracked_parents_and_index_policy() {
3975        let (root, workspace, graph) = fixture(Some(json!(["{projectRoot}/dist"])));
3976        std::fs::create_dir_all(root.path().join("tracked/nested")).unwrap();
3977        std::fs::write(root.path().join("tracked/.gitignore"), "nested/\n").unwrap();
3978        std::fs::write(root.path().join("tracked/nested/source"), "one").unwrap();
3979        assert!(
3980            paths::git(root.path(), &["init", "-q"])
3981                .unwrap()
3982                .status
3983                .success()
3984        );
3985        assert!(
3986            paths::git(root.path(), &["add", "-f", "tracked/nested/source"])
3987                .unwrap()
3988                .status
3989                .success()
3990        );
3991        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
3992        let files = || snapshot.current_files(root.path()).unwrap();
3993        assert!(files().contains("tracked/nested/source"));
3994        std::fs::remove_file(root.path().join("tracked/nested/source")).unwrap();
3995        assert!(!files().contains("tracked/nested/source"));
3996        std::fs::write(root.path().join("tracked/nested/another"), "two").unwrap();
3997        assert!(!files().contains("tracked/nested/another"));
3998        assert!(
3999            paths::git(root.path(), &["add", "-f", "tracked/nested/another"])
4000                .unwrap()
4001                .status
4002                .success()
4003        );
4004        assert!(files().contains("tracked/nested/another"));
4005        std::fs::write(root.path().join("untracked"), "three").unwrap();
4006        assert!(files().contains("untracked"));
4007        std::fs::write(root.path().join(".git/info/exclude"), "untracked\n").unwrap();
4008        assert!(!files().contains("untracked"));
4009    }
4010
4011    #[test]
4012    fn membership_watches_ignore_files_found_deep_in_the_walk() {
4013        let (root, workspace, graph) = fixture(None);
4014        let nested = root.path().join("packages/app/src/deep");
4015        std::fs::create_dir_all(&nested).unwrap();
4016        std::fs::write(nested.join("kept"), "a").unwrap();
4017        std::fs::write(nested.join("hidden"), "b").unwrap();
4018        let policy = root.path().join("packages/app/.gitignore");
4019        std::fs::write(&policy, "\n").unwrap();
4020        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
4021        let files = || snapshot.current_files(root.path()).unwrap();
4022        files();
4023        // Waiting lets the listing settle, so it is reused, as in
4024        // `membership_reuses_settled_candidates_and_detects_directory_changes`.
4025        std::thread::sleep(Duration::from_millis(2100));
4026        let before = files();
4027        assert!(Arc::ptr_eq(&before, &files()));
4028        assert!(before.contains("packages/app/src/deep/hidden"));
4029        // Edited in place, so no directory's names change: only the policy
4030        // the walk found can refuse the settled listing.
4031        std::fs::write(&policy, "hidden\n").unwrap();
4032        let after = files();
4033        assert!(!after.contains("packages/app/src/deep/hidden"));
4034        assert!(after.contains("packages/app/src/deep/kept"));
4035    }
4036
4037    #[test]
4038    fn a_new_git_config_include_invalidates_settled_listing_policy() {
4039        let (root, workspace, graph) = fixture(Some(json!(["{projectRoot}/dist"])));
4040        let policy = tempfile::tempdir().unwrap();
4041        let included = policy.path().join("included.config");
4042        let excludes = policy.path().join("excludes");
4043        std::fs::write(&excludes, "hidden\n").unwrap();
4044        std::fs::write(root.path().join("hidden"), "source").unwrap();
4045        assert!(
4046            paths::git(root.path(), &["init", "-q"])
4047                .unwrap()
4048                .status
4049                .success()
4050        );
4051        assert!(
4052            paths::git(
4053                root.path(),
4054                &["config", "include.path", included.to_str().unwrap()]
4055            )
4056            .unwrap()
4057            .status
4058            .success()
4059        );
4060        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
4061        snapshot.current_files(&workspace.root).unwrap();
4062        std::thread::sleep(Duration::from_millis(2100));
4063        let before = snapshot.current_files(&workspace.root).unwrap();
4064        assert!(before.contains("hidden"));
4065        assert!(Arc::ptr_eq(
4066            &before,
4067            &snapshot.current_files(&workspace.root).unwrap()
4068        ));
4069        assert!(
4070            paths::git(
4071                root.path(),
4072                &[
4073                    "config",
4074                    "--file",
4075                    included.to_str().unwrap(),
4076                    "core.excludesfile",
4077                    excludes.to_str().unwrap(),
4078                ]
4079            )
4080            .unwrap()
4081            .status
4082            .success()
4083        );
4084        assert!(
4085            !snapshot
4086                .current_files(&workspace.root)
4087                .unwrap()
4088                .contains("hidden")
4089        );
4090    }
4091
4092    #[cfg(unix)]
4093    #[test]
4094    fn git_policy_paths_preserve_quoted_origins_and_multiline_values() {
4095        let (root, workspace, graph) = fixture(Some(json!(["{projectRoot}/dist"])));
4096        let policy = tempfile::tempdir().unwrap();
4097        // Git's text output C-quotes these origins and octal-escapes UTF-8.
4098        let included = policy.path().join("included\\é\t\n.config");
4099        let excludes = policy.path().join("excludes\\é\t\n");
4100        let replacement = policy.path().join("replacement\n");
4101        std::fs::write(&excludes, "hidden\n").unwrap();
4102        std::fs::write(&replacement, "visible\n").unwrap();
4103        for file in ["hidden", "visible"] {
4104            std::fs::write(root.path().join(file), "source").unwrap();
4105        }
4106        let git = |args: &[&str]| {
4107            let output = paths::git(root.path(), args).unwrap();
4108            assert!(output.status.success(), "{output:?}");
4109            output
4110        };
4111        git(&["init", "-q"]);
4112        git(&["config", "include.path", included.to_str().unwrap()]);
4113        git(&[
4114            "config",
4115            "--file",
4116            included.to_str().unwrap(),
4117            "core.excludesfile",
4118            excludes.to_str().unwrap(),
4119        ]);
4120        let text = git(&["config", "--show-origin", "--list"]);
4121        let text = String::from_utf8_lossy(&text.stdout);
4122        let quoted_origin = text
4123            .lines()
4124            .find_map(|line| {
4125                line.strip_prefix("file:\"")
4126                    .and_then(|_| line.split_once('\t'))
4127            })
4128            .unwrap()
4129            .0
4130            .strip_prefix("file:")
4131            .unwrap();
4132        let policies = git_membership_policies(root.path());
4133        assert!(!policies.contains(&root.path().join(quoted_origin)));
4134        assert!(policies.contains(&included));
4135        assert!(policies.contains(&excludes));
4136        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
4137        let initial = snapshot.current_files(&workspace.root).unwrap();
4138        assert!(!initial.contains("hidden"));
4139        assert!(initial.contains("visible"));
4140        std::thread::sleep(Duration::from_millis(2100));
4141        let settled = snapshot.current_files(&workspace.root).unwrap();
4142        assert!(Arc::ptr_eq(
4143            &settled,
4144            &snapshot.current_files(&workspace.root).unwrap()
4145        ));
4146        git(&[
4147            "config",
4148            "--file",
4149            included.to_str().unwrap(),
4150            "core.excludesfile",
4151            replacement.to_str().unwrap(),
4152        ]);
4153        let changed = snapshot.current_files(&workspace.root).unwrap();
4154        assert!(changed.contains("hidden"));
4155        assert!(!changed.contains("visible"));
4156        assert!(git_membership_policies(root.path()).contains(&replacement));
4157    }
4158
4159    #[cfg(unix)]
4160    #[test]
4161    fn symlinked_ignore_policy_never_reuses_stale_membership() {
4162        let (root, workspace, graph) = fixture(Some(json!(["{projectRoot}/dist"])));
4163        let policy = tempfile::NamedTempFile::new().unwrap();
4164        std::fs::write(policy.path(), "hidden\n").unwrap();
4165        std::os::unix::fs::symlink(policy.path(), root.path().join(".gitignore")).unwrap();
4166        std::fs::write(root.path().join("hidden"), "source").unwrap();
4167        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
4168        assert!(
4169            !snapshot
4170                .current_files(root.path())
4171                .unwrap()
4172                .contains("hidden")
4173        );
4174        assert!(
4175            !snapshot
4176                .membership
4177                .lock()
4178                .unwrap()
4179                .as_ref()
4180                .unwrap()
4181                .settled
4182        );
4183        std::thread::sleep(Duration::from_millis(2100));
4184        let before = snapshot.current_files(root.path()).unwrap();
4185        assert!(Arc::ptr_eq(
4186            &before,
4187            &snapshot.current_files(root.path()).unwrap()
4188        ));
4189        std::fs::write(policy.path(), "").unwrap();
4190        assert!(
4191            snapshot
4192                .current_files(root.path())
4193                .unwrap()
4194                .contains("hidden")
4195        );
4196    }
4197
4198    /// An input linking to a directory keys every file beneath it, the
4199    /// Git-ignored ones included, such as a dotenv file there; one linking to
4200    /// the workspace root cannot be keyed.
4201    #[cfg(unix)]
4202    #[test]
4203    fn a_linked_directory_input_keys_ignored_files_beneath_it() {
4204        let root = tempfile::tempdir().unwrap();
4205        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
4206        std::fs::write(
4207            root.path().join("project.json"),
4208            json!({"name": "app", "targets": {
4209                "read": {"command": "true", "inputs": ["{projectRoot}/linked"]},
4210                "whole": {"command": "true", "inputs": ["{projectRoot}/everything"]}
4211            }})
4212            .to_string(),
4213        )
4214        .unwrap();
4215        std::fs::write(root.path().join(".gitignore"), ".env\n").unwrap();
4216        std::fs::create_dir(root.path().join("shared")).unwrap();
4217        std::fs::write(root.path().join("shared/a.ts"), "a").unwrap();
4218        std::fs::write(root.path().join("shared/.env"), "X=1\n").unwrap();
4219        std::os::unix::fs::symlink("shared", root.path().join("linked")).unwrap();
4220        std::os::unix::fs::symlink(".", root.path().join("everything")).unwrap();
4221        assert!(
4222            paths::git(root.path(), &["init", "--quiet"])
4223                .unwrap()
4224                .status
4225                .success()
4226        );
4227        let workspace = Workspace::load(root.path()).unwrap();
4228        let requests = ["app:read", "app:whole"].map(|request| Request::parse(request).unwrap());
4229        let graph = TaskGraph::build(&workspace, &requests).unwrap();
4230        let key = |id: &str| {
4231            let task = &graph.tasks[id];
4232            let prepared = qk_executor::prepare(&workspace, task, &BTreeMap::new()).unwrap();
4233            let snapshot =
4234                Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
4235            inputs(
4236                &snapshot,
4237                &workspace,
4238                task,
4239                &prepared,
4240                &BTreeMap::new(),
4241                &AtomicBool::new(false),
4242            )
4243        };
4244        let before = key("app:read").unwrap();
4245        std::fs::write(root.path().join("shared/.env"), "X=2\n").unwrap();
4246        assert_ne!(before, key("app:read").unwrap());
4247        let error = format!("{:#}", key("app:whole").unwrap_err());
4248        assert!(error.contains("resolves to the workspace root"), "{error}");
4249    }
4250
4251    #[cfg(unix)]
4252    #[test]
4253    fn recent_change_time_prevents_membership_reuse_despite_old_mtime() {
4254        use std::os::unix::fs::MetadataExt;
4255        let root = tempfile::tempdir().unwrap();
4256        let old = SystemTime::UNIX_EPOCH + Duration::from_secs(1);
4257        File::open(root.path())
4258            .unwrap()
4259            .set_times(std::fs::FileTimes::new().set_modified(old))
4260            .unwrap();
4261        let metadata = std::fs::metadata(root.path()).unwrap();
4262        assert!(metadata.ctime() > 1);
4263        let stamp = Stamp::new(&metadata);
4264        assert!(stamp.settled());
4265        assert!(!stamp.membership_settled());
4266    }
4267
4268    #[test]
4269    fn inspection_keeps_deleted_candidates() {
4270        let (root, workspace, graph) = fixture(Some(json!(["{projectRoot}/dist"])));
4271        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache"))
4272            .unwrap()
4273            .with_candidates(&["src/deleted".into()]);
4274        let resolved = resolve(
4275            &snapshot,
4276            &workspace,
4277            &graph.tasks["app:post"],
4278            None,
4279            &AtomicBool::new(false),
4280        )
4281        .unwrap();
4282        assert!(resolved.declared.files.contains("src/deleted"));
4283    }
4284
4285    #[test]
4286    fn undeclared_writes_invalidate_dependency_values() {
4287        for outputs in [None, Some(json!(["{projectRoot}/generated"]))] {
4288            let (root, workspace, graph) = fixture(outputs);
4289            let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
4290            let keys = || {
4291                dependency_keys(
4292                    &snapshot,
4293                    &workspace,
4294                    &graph,
4295                    &graph.tasks["app:build"],
4296                    &AtomicBool::new(false),
4297                )
4298                .unwrap()
4299            };
4300            let before = keys();
4301            std::fs::write(root.path().join("generated/value"), "different").unwrap();
4302            snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
4303            assert_ne!(before, keys());
4304        }
4305    }
4306
4307    #[cfg(unix)]
4308    #[test]
4309    fn dependency_symlinks_recheck_targets_outside_output_roots() {
4310        let (root, mut workspace, _) = fixture(Some(json!(["{projectRoot}/generated"])));
4311        workspace
4312            .projects
4313            .get_mut("app")
4314            .unwrap()
4315            .targets
4316            .get_mut("gen")
4317            .unwrap()
4318            .outputs = Some(vec!["{projectRoot}/links".into()]);
4319        std::fs::create_dir(root.path().join("links")).unwrap();
4320        std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
4321        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
4322        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
4323        let keys = || {
4324            dependency_keys(
4325                &snapshot,
4326                &workspace,
4327                &graph,
4328                &graph.tasks["app:build"],
4329                &AtomicBool::new(false),
4330            )
4331            .unwrap()
4332        };
4333        let before = keys();
4334        std::fs::write(root.path().join("generated/value"), "different").unwrap();
4335        snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
4336        assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
4337    }
4338    /// The digest of a git-ignored file an `includeIgnored` fileset reads is
4339    /// saved with the others, so a warm run does not read it again.
4340    #[test]
4341    fn digests_of_ignored_files_read_by_include_ignored_are_saved() {
4342        let root = tempfile::tempdir().unwrap();
4343        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
4344        std::fs::write(root.path().join(".gitignore"), "generated/\n").unwrap();
4345        std::fs::write(
4346            root.path().join("project.json"),
4347            json!({"name": "app", "targets": {"build": {"command": "true", "inputs": [
4348                {"fileset": "{projectRoot}/generated/**/*", "includeIgnored": true}
4349            ]}}})
4350            .to_string(),
4351        )
4352        .unwrap();
4353        std::fs::create_dir(root.path().join("generated")).unwrap();
4354        std::fs::write(root.path().join("generated/schema.json"), "{}").unwrap();
4355        // Digests are only saved for files old enough to have settled.
4356        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
4357        std::fs::File::options()
4358            .write(true)
4359            .open(root.path().join("generated/schema.json"))
4360            .unwrap()
4361            .set_times(std::fs::FileTimes::new().set_modified(old))
4362            .unwrap();
4363        let git = |args: &[&str]| assert!(paths::git(root.path(), args).unwrap().status.success());
4364        git(&["init", "--quiet"]);
4365        let workspace = Workspace::load(root.path()).unwrap();
4366        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
4367        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
4368        assert!(!snapshot.files.contains("generated/schema.json"));
4369        let prepared =
4370            qk_executor::prepare(&workspace, &graph.tasks["app:build"], &BTreeMap::new()).unwrap();
4371        inputs(
4372            &snapshot,
4373            &workspace,
4374            &graph.tasks["app:build"],
4375            &prepared,
4376            &BTreeMap::new(),
4377            &AtomicBool::new(false),
4378        )
4379        .unwrap();
4380        snapshot.save_digests(root.path());
4381        assert!(load_digests(root.path()).contains_key("generated/schema.json"));
4382    }
4383}