Skip to main content

qk_cache/
hash.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::ffi::OsString;
3use std::fs::File;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::AtomicBool;
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, 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::glob::is_literal;
20use crate::inputs::{Resolved, manifest_for_dependents, workspace_without_resolution};
21use crate::paths::{self, Outputs, Pattern};
22
23pub fn digest_file(path: &Path) -> Result<String> {
24    let mut hasher = blake3::Hasher::new();
25    let mut reader = File::open(path)?;
26    let mut buffer = [0; 64 * 1024];
27    loop {
28        let count = reader.read(&mut buffer)?;
29        if count == 0 {
30            break;
31        }
32        hasher.update(&buffer[..count]);
33    }
34    Ok(hasher.finalize().to_hex().to_string())
35}
36
37/// Metadata that changes whenever a file's content can have changed: size,
38/// modification time and, on Unix, change time, inode and mode.
39#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
40struct Stamp(Vec<i64>);
41
42impl Stamp {
43    fn new(metadata: &std::fs::Metadata) -> Self {
44        let modified = metadata
45            .modified()
46            .ok()
47            .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
48            .map_or(-1, |duration| duration.as_nanos() as i64);
49        #[allow(unused_mut)]
50        let mut values = vec![metadata.len() as i64, modified];
51        #[cfg(unix)]
52        {
53            use std::os::unix::fs::MetadataExt;
54            values.extend([
55                metadata.ctime(),
56                metadata.ctime_nsec(),
57                metadata.ino() as i64,
58                i64::from(metadata.mode()),
59            ]);
60        }
61        Self(values)
62    }
63
64    /// Membership stamps also use change time: restoring a directory's mtime
65    /// must not allow changes inside a coarse timestamp window to go unnoticed.
66    fn membership_settled(&self) -> bool {
67        #[cfg(unix)]
68        let width = 6;
69        #[cfg(not(unix))]
70        let width = 2;
71        self.0.chunks_exact(width).all(|values| {
72            let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(values[1].max(0) as u64);
73            let settled = |time| {
74                SystemTime::now()
75                    .duration_since(time)
76                    .is_ok_and(|age| age > Duration::from_secs(2))
77            };
78            if values[1] < 0 || !settled(modified) {
79                return false;
80            }
81            #[cfg(unix)]
82            {
83                let changed = SystemTime::UNIX_EPOCH
84                    + Duration::new(values[2].max(0) as u64, values[3].max(0) as u32);
85                values[2] >= 0 && settled(changed)
86            }
87            #[cfg(not(unix))]
88            true
89        })
90    }
91
92    /// A file written within the timestamp resolution window could change again
93    /// without changing its stamp, so recent files are always re-read.
94    fn settled(&self) -> bool {
95        let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
96        self.0[1] >= 0
97            && SystemTime::now()
98                .duration_since(modified)
99                .is_ok_and(|age| age > Duration::from_secs(2))
100    }
101}
102
103/// Digests persisted between runs in the worktree's state, keyed by path and
104/// stamp, so a warm run reads only the files that changed. Stamps include the
105/// inode, so they belong to one worktree.
106fn digests_path(root: &Path) -> PathBuf {
107    paths::worktree_state(root).join("digests.json")
108}
109
110#[derive(PartialEq, Eq, Hash)]
111struct RuntimeKey {
112    command: String,
113    environment: BTreeMap<OsString, OsString>,
114}
115
116/// Workspace state shared by every fingerprint in one run. Inspection uses the
117/// initial candidate list; execution fingerprints refresh membership so files
118/// added or removed during the run cannot escape input verification.
119pub struct Snapshot {
120    pub(crate) files: BTreeSet<String>,
121    extra_candidates: BTreeSet<String>,
122    membership: Mutex<Option<Arc<Membership>>>,
123    membership_prefixes: Mutex<HashMap<String, Arc<Vec<PathBuf>>>>,
124    source_exclusions: Vec<Outputs>,
125    cache_relative: Option<PathBuf>,
126    projects: ProjectGraph,
127    /// Each project by its root, to find the project owning a file.
128    roots: BTreeMap<String, String>,
129    generated_tasks: Vec<(Task, Outputs)>,
130    task_dependencies: BTreeMap<String, BTreeSet<String>>,
131    source_ignore: Arc<SourceIgnore>,
132    canonical_root: PathBuf,
133    workspace_prefix: Option<PathBuf>,
134    patterns: Mutex<HashMap<(String, bool), Arc<Pattern>>>,
135    digests: Mutex<HashMap<String, (Stamp, String)>>,
136    /// Whether `digests` gained entries worth saving.
137    digests_changed: std::sync::atomic::AtomicBool,
138    /// Directories already checked not to be symlinks.
139    directories: Mutex<HashSet<PathBuf>>,
140    /// Runtime input results by command and environment, computed once per run like Nx.
141    runtime: Mutex<HashMap<RuntimeKey, Arc<Mutex<Option<Value>>>>>,
142    /// The parsed pnpm lockfile, replaced whenever its content changes.
143    lockfile: Mutex<Option<Arc<Installed>>>,
144    /// Each dependency task's output files, listed when a task first asks and
145    /// dropped once a task whose outputs overlap them is done.
146    dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
147    /// Counts drops, so a listing taken while a task finished is not kept.
148    outputs_written: std::sync::atomic::AtomicU64,
149}
150
151/// Membership depends on directory entries and Git/ignore listing policy, not
152/// on every source file's contents. Empty directories must also be watched.
153#[derive(Clone)]
154struct Membership {
155    files: Arc<BTreeSet<String>>,
156    source_ignore: Arc<SourceIgnore>,
157    stamps: BTreeMap<PathBuf, Option<Stamp>>,
158    directories: BTreeSet<PathBuf>,
159    directory_entries: BTreeMap<PathBuf, BTreeSet<(OsString, u8)>>,
160    root: PathBuf,
161    outputs: Vec<Outputs>,
162    output_anchors: BTreeMap<PathBuf, Vec<usize>>,
163    excluded_roots: BTreeSet<PathBuf>,
164    cache: Option<PathBuf>,
165    policies: BTreeSet<PathBuf>,
166    git_policies: BTreeSet<PathBuf>,
167    ignore_contents: BTreeMap<PathBuf, String>,
168    settled: bool,
169    /// When the refresh that produced this inventory began listing.
170    listed: Option<Instant>,
171}
172
173/// A candidate listing and the ignore policy captured with it. Keeping both
174/// alive prevents a concurrent refresh from changing dependency-output selection.
175#[derive(Clone)]
176struct Candidates {
177    files: Arc<BTreeSet<String>>,
178    source_ignore: Arc<SourceIgnore>,
179}
180
181impl Membership {
182    /// Inventory source directories and policy stamps before a new listing.
183    fn new(
184        root: &Path,
185        files: &BTreeSet<String>,
186        source_ignore: &Arc<SourceIgnore>,
187        outputs: &[Outputs],
188        cache: Option<&Path>,
189        git_policies: Option<&BTreeSet<PathBuf>>,
190    ) -> Result<Self> {
191        let excluded: BTreeSet<_> = outputs
192            .iter()
193            .flat_map(Outputs::complete_roots)
194            .map(PathBuf::from)
195            .collect();
196        let excluded_roots = excluded.clone();
197        let walk_root = root.to_owned();
198        let walk_cache = cache.map(PathBuf::from);
199        let mut directories = BTreeSet::new();
200        let mut stamps = BTreeMap::new();
201        for entry in ignore::WalkBuilder::new(root)
202            .hidden(false)
203            .parents(false)
204            .git_global(false)
205            .require_git(false)
206            .ignore(false)
207            .git_ignore(false)
208            .git_exclude(false)
209            .follow_links(false)
210            .filter_entry(move |entry| {
211                let relative = entry
212                    .path()
213                    .strip_prefix(&walk_root)
214                    .unwrap_or(entry.path());
215                !matches!(
216                    entry.file_name().to_str(),
217                    Some(".git" | ".qk" | "node_modules")
218                ) && walk_cache
219                    .as_ref()
220                    .is_none_or(|cache| !relative.starts_with(cache))
221                    && !relative.ancestors().any(|path| excluded.contains(path))
222            })
223            .build()
224        {
225            let entry = entry?;
226            if entry.file_type().is_some_and(|kind| kind.is_dir()) {
227                let directory = entry.path().to_owned();
228                stamps.insert(directory.clone(), membership_stamp(&directory)?);
229                directories.insert(directory);
230            }
231        }
232        // Git lists tracked files even beneath ignored directories. Watching
233        // only the ignore walker would miss deletion/rename of those files.
234        for path in files {
235            for parent in Path::new(path).ancestors().skip(1) {
236                let directory = root.join(parent);
237                if directories.insert(directory.clone()) {
238                    stamps.insert(directory.clone(), membership_stamp(&directory)?);
239                }
240            }
241        }
242        let mut policies = BTreeSet::new();
243        for directory in &directories {
244            for name in [".gitignore", ".nxignore"] {
245                let path = directory.join(name);
246                // Creation of a new ignore file changes its directory stamp.
247                // Existing rules can be edited without changing the directory.
248                if membership_stamp(&path)?.is_some() {
249                    policies.insert(path);
250                }
251            }
252        }
253        for directory in root.ancestors() {
254            policies.insert(directory.join(".gitignore"));
255            // Also detect a newly initialized repository or changed worktree pointer.
256            policies.insert(directory.join(".git"));
257        }
258        let git_policies = git_policies
259            .cloned()
260            .unwrap_or_else(|| git_membership_policies(root));
261        policies.extend(git_policies.iter().cloned());
262        for policy in &policies {
263            stamps.insert(policy.clone(), membership_stamp(policy)?);
264        }
265        // Capture contents before listing, so a later policy change cannot be
266        // mistaken for the rules that produced that listing.
267        let mut ignore_contents = BTreeMap::new();
268        for policy in &policies {
269            if matches!(
270                policy.file_name().and_then(|name| name.to_str()),
271                Some(".gitignore" | ".nxignore")
272            ) && let Some(content) = ignore_content(policy, &stamps[policy])?
273            {
274                ignore_contents.insert(policy.clone(), content);
275            }
276        }
277        let mut output_anchors: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
278        for (index, outputs) in outputs.iter().enumerate() {
279            for anchor in outputs.anchors() {
280                output_anchors
281                    .entry(PathBuf::from(anchor))
282                    .or_default()
283                    .push(index);
284            }
285        }
286        let mut membership = Self {
287            files: Arc::new(BTreeSet::new()),
288            source_ignore: source_ignore.clone(),
289            stamps,
290            directories,
291            directory_entries: BTreeMap::new(),
292            root: root.to_owned(),
293            outputs: outputs.to_vec(),
294            output_anchors,
295            excluded_roots,
296            cache: cache.map(PathBuf::from),
297            policies,
298            git_policies,
299            ignore_contents,
300            settled: false,
301            listed: None,
302        };
303        for directory in &membership.directories {
304            if let Some(entries) = membership.entries(directory)? {
305                membership
306                    .directory_entries
307                    .insert(directory.clone(), entries);
308            }
309        }
310        Ok(membership)
311    }
312
313    /// Names and types that can affect source membership in this directory.
314    /// Output files may be skipped, but partially excluded output directories
315    /// must remain visible because they can contain source files or ignore rules.
316    fn entries(&self, directory: &Path) -> Result<Option<BTreeSet<(OsString, u8)>>> {
317        let entries = match std::fs::read_dir(directory) {
318            Ok(entries) => entries,
319            Err(error)
320                if matches!(
321                    error.kind(),
322                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
323                ) =>
324            {
325                return Ok(None);
326            }
327            Err(error) => return Err(error.into()),
328        };
329        let relative_directory = directory.strip_prefix(&self.root).unwrap_or(directory);
330        let mut outputs = BTreeSet::new();
331        for ancestor in relative_directory.ancestors() {
332            if let Some(indices) = self.output_anchors.get(ancestor) {
333                outputs.extend(indices.iter().copied());
334            }
335        }
336        for (_, indices) in self
337            .output_anchors
338            .range(relative_directory.to_owned()..)
339            .take_while(|(anchor, _)| anchor.starts_with(relative_directory))
340        {
341            outputs.extend(indices.iter().copied());
342        }
343        let mut names = BTreeSet::new();
344        for entry in entries {
345            let entry = entry?;
346            let name = entry.file_name();
347            let path = entry.path();
348            let relative = path.strip_prefix(&self.root).unwrap_or(&path);
349            if matches!(name.to_str(), Some(".git" | ".qk" | "node_modules"))
350                || self
351                    .cache
352                    .as_ref()
353                    .is_some_and(|cache| relative.starts_with(cache))
354                || relative
355                    .ancestors()
356                    .any(|root| self.excluded_roots.contains(root))
357            {
358                continue;
359            }
360            // A policy created between policy discovery and name capture must
361            // get its own stamp before its in-place edits can permit reuse.
362            if matches!(name.to_str(), Some(".gitignore" | ".nxignore"))
363                && !self.policies.contains(&path)
364            {
365                return Ok(None);
366            }
367            let kind = entry.file_type()?;
368            // An empty directory created after the inventory walk would not
369            // appear among source files. Never bless its parent signature until
370            // that directory is itself watched by the inventory.
371            if kind.is_dir() && !self.directories.contains(&path) {
372                return Ok(None);
373            }
374            if !kind.is_dir()
375                && !matches!(name.to_str(), Some(".gitignore" | ".nxignore"))
376                && relative.to_str().is_some_and(|path| {
377                    outputs
378                        .iter()
379                        .any(|index| self.outputs[*index].matches(path))
380                })
381            {
382                continue;
383            }
384            names.insert((
385                name,
386                if kind.is_dir() {
387                    1
388                } else if kind.is_symlink() {
389                    2
390                } else {
391                    0
392                },
393            ));
394        }
395        Ok(Some(names))
396    }
397
398    /// Check source-relevant directory membership and every listing policy.
399    /// Recent directory stamps require fresh entry names; recent ignore files
400    /// require matching contents, while other listing policies must settle.
401    fn state(&self, prefixes: Option<&[PathBuf]>) -> Result<(bool, bool)> {
402        let mut settled = true;
403        let selected = prefixes.map(|prefixes| {
404            let mut selected = BTreeSet::new();
405            for prefix in prefixes {
406                for ancestor in prefix.ancestors() {
407                    if let Some(directory) = self.directories.get(ancestor) {
408                        selected.insert(directory);
409                    }
410                }
411                selected.extend(
412                    self.directories
413                        .range(prefix.clone()..)
414                        .take_while(|directory| directory.starts_with(prefix)),
415                );
416            }
417            selected
418        });
419        let paths: Box<dyn Iterator<Item = (&PathBuf, bool)>> = match selected {
420            Some(selected) => Box::new(
421                selected
422                    .into_iter()
423                    .map(|path| (path, true))
424                    .chain(self.policies.iter().map(|path| (path, false))),
425            ),
426            None => Box::new(
427                self.stamps
428                    .keys()
429                    .map(|path| (path, self.directories.contains(path))),
430            ),
431        };
432        for (path, directory) in paths {
433            let before = &self.stamps[path];
434            let after = membership_stamp(path)?;
435            if directory {
436                if after.is_some() && !self.directory_entries.contains_key(path) {
437                    return Ok((false, false));
438                }
439                // Output publication changes its parent's timestamp too. Check
440                // source-relevant names rather than relisting the whole workspace.
441                // Recent directories are always read, even with an equal stamp:
442                // coarse timestamps can conceal an addition or removal.
443                if *before != after
444                    || after
445                        .as_ref()
446                        .is_some_and(|stamp| !stamp.membership_settled())
447                {
448                    let entries = self.entries(path)?;
449                    if entries.as_ref() != self.directory_entries.get(path)
450                        || membership_stamp(path)? != after
451                    {
452                        return Ok((false, false));
453                    }
454                }
455            } else {
456                let recent = after
457                    .as_ref()
458                    .is_some_and(|stamp| !stamp.membership_settled());
459                if (*before != after || recent)
460                    && let Some(content) = self.ignore_contents.get(path)
461                {
462                    if ignore_content(path, &after)?.as_ref() != Some(content) {
463                        return Ok((false, false));
464                    }
465                    continue;
466                }
467                if *before != after {
468                    return Ok((false, false));
469                }
470                settled &= !recent;
471            }
472        }
473        Ok((true, settled))
474    }
475
476    /// Retain a matched listing and policy for use after releasing the lock.
477    fn candidates(&self) -> Candidates {
478        Candidates {
479            files: self.files.clone(),
480            source_ignore: self.source_ignore.clone(),
481        }
482    }
483}
484
485/// Find Git index, exclusion and config files that can change source visibility,
486/// including absent policy files whose later creation must invalidate reuse.
487fn git_membership_policies(root: &Path) -> BTreeSet<PathBuf> {
488    let mut policies = BTreeSet::new();
489    if let Ok(output) = paths::git(
490        root,
491        &[
492            "rev-parse",
493            "--path-format=absolute",
494            "--git-path",
495            "index",
496            "--git-path",
497            "info/exclude",
498            "--git-path",
499            "config",
500            "--git-path",
501            "config.worktree",
502        ],
503    ) && output.status.success()
504        && let Ok(paths) = String::from_utf8(output.stdout)
505    {
506        policies.extend(paths.lines().map(PathBuf::from));
507    }
508    // Config can include other files. Track every origin used by Git, as
509    // well as currently missing standard config and excludes files.
510    if let Ok(output) = paths::git(root, &["config", "--null", "--show-origin", "--list"])
511        && output.status.success()
512    {
513        // NUL mode emits raw origins, without Git's C-style path quoting,
514        // and separates each origin from its key/newline/value record. Values
515        // may themselves contain newlines (including include/excludes paths).
516        let output = String::from_utf8_lossy(&output.stdout);
517        let mut fields = output.split_terminator('\0');
518        while let (Some(origin), Some(entry)) = (fields.next(), fields.next()) {
519            if let Some(path) = origin.strip_prefix("file:") {
520                let origin = root.join(path);
521                if let Some((name, value)) = entry.split_once('\n')
522                    && name.starts_with("include")
523                    && name.ends_with(".path")
524                {
525                    let included = if let Some(relative) = value.strip_prefix("~/") {
526                        std::env::var_os("HOME")
527                            .map(PathBuf::from)
528                            .unwrap_or_default()
529                            .join(relative)
530                    } else {
531                        origin.parent().unwrap_or(root).join(value)
532                    };
533                    // Git omits missing includes from the origin listing, but
534                    // creating one later can change core.excludesfile.
535                    policies.insert(included);
536                }
537                policies.insert(origin);
538            }
539        }
540    }
541    if let Ok(output) = paths::git(
542        root,
543        &["config", "--null", "--path", "--get", "core.excludesfile"],
544    ) && output.status.success()
545        && let Ok(path) = String::from_utf8(output.stdout)
546    {
547        policies.insert(root.join(path.strip_suffix('\0').unwrap_or(&path)));
548    }
549    for variable in ["GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL"] {
550        if let Some(path) = std::env::var_os(variable) {
551            policies.insert(PathBuf::from(path));
552        }
553    }
554    policies.insert(PathBuf::from("/etc/gitconfig"));
555    if let Some(home) = std::env::var_os("HOME") {
556        let home = PathBuf::from(home);
557        policies.insert(home.join(".gitconfig"));
558        let config = std::env::var_os("XDG_CONFIG_HOME")
559            .map(PathBuf::from)
560            .unwrap_or_else(|| home.join(".config"));
561        policies.insert(config.join("git/config"));
562        policies.insert(config.join("git/ignore"));
563    }
564    policies
565}
566
567/// Read a regular ignore policy only when the captured metadata remains stable.
568/// A digest read after a listing must never replace the pre-listing digest.
569fn ignore_content(path: &Path, expected: &Option<Stamp>) -> Result<Option<String>> {
570    if expected.is_none() || membership_stamp(path)? != *expected {
571        return Ok(None);
572    }
573    match std::fs::symlink_metadata(path) {
574        Ok(metadata) if metadata.is_file() => {}
575        Ok(_) => return Ok(None),
576        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
577        Err(error) => return Err(error.into()),
578    }
579    let content = match std::fs::read(path) {
580        Ok(content) => content,
581        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
582        Err(error) => return Err(error.into()),
583    };
584    Ok((membership_stamp(path)? == *expected).then(|| blake3::hash(&content).to_hex().to_string()))
585}
586
587/// Stamp a directory or listing policy, including a symlink's resolved target.
588fn membership_stamp(path: &Path) -> Result<Option<Stamp>> {
589    match std::fs::symlink_metadata(path) {
590        Ok(metadata) => {
591            let mut stamp = Stamp::new(&metadata);
592            if metadata.is_symlink() {
593                // Follow the current chain on every check. In-place target
594                // edits or retargeted intermediate links can change policy.
595                match std::fs::metadata(path) {
596                    Ok(target) => stamp.0.extend(Stamp::new(&target).0),
597                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => stamp.0[1] = -1,
598                    Err(error) => return Err(error.into()),
599                }
600            }
601            Ok(Some(stamp))
602        }
603        Err(error)
604            if matches!(
605                error.kind(),
606                std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
607            ) =>
608        {
609            Ok(None)
610        }
611        Err(error) => Err(error).with_context(|| format!("cannot inspect {}", path.display())),
612    }
613}
614
615/// One dependency task's output files, and the value of each one a pattern
616/// has selected so far.
617struct DependencyOutputs {
618    anchors: Vec<String>,
619    files: Vec<String>,
620    values: Mutex<HashMap<String, Value>>,
621}
622
623/// One revision of `pnpm-lock.yaml`, with digests of what it installs computed
624/// once and shared by every task that asks.
625struct Installed {
626    content: String,
627    /// `None` when the file cannot be read as a pnpm v9 lockfile.
628    lockfile: Option<Lockfile>,
629    digests: Mutex<HashMap<String, String>>,
630}
631
632impl Installed {
633    fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
634        if let Some(digest) = self.digests.lock().unwrap().get(key) {
635            return digest.clone();
636        }
637        let mut hasher = blake3::Hasher::new();
638        for fingerprint in fingerprints() {
639            hasher.update(fingerprint.as_bytes());
640            hasher.update(b"\n");
641        }
642        let digest = hasher.finalize().to_hex().to_string();
643        self.digests
644            .lock()
645            .unwrap()
646            .insert(key.to_owned(), digest.clone());
647        digest
648    }
649}
650
651impl Snapshot {
652    pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
653        // Every early task needs these, so loading saved digests and parsing
654        // the lockfile overlap listing files.
655        let canonical_root = workspace.root.canonicalize()?;
656        let (files, lockfile, (digests, workspace_prefix)) = std::thread::scope(|scope| {
657            let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
658            let digests = scope.spawn(|| {
659                let digests = load_digests(&workspace.root);
660                let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
661                    .and_then(|root| root.canonicalize().ok())
662                    .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
663                (digests, workspace_prefix)
664            });
665            let files = source_files(&workspace.root);
666            (
667                files,
668                lockfile.join().expect("lockfile thread panicked"),
669                digests.join().expect("digest thread panicked"),
670            )
671        });
672        let mut files = files?;
673        let cache_relative = cache_path
674            .strip_prefix(&workspace.root)
675            .ok()
676            .map(PathBuf::from);
677        // Keep the patterns, not only the artifacts present at startup: new
678        // outputs and warm scratch files must be excluded on every refresh.
679        let mut source_exclusions: Vec<_> = graph
680            .tasks
681            .values()
682            .filter_map(|task| Outputs::new(workspace, task).ok())
683            .filter(Outputs::is_explicit)
684            .collect();
685        for task in graph.tasks.values() {
686            let Ok(entries) = crate::warm::config(workspace, task) else {
687                continue;
688            };
689            if let Ok(paths) = Outputs::from_paths(&crate::warm::kept_paths(&entries)) {
690                source_exclusions.push(paths);
691            }
692        }
693        exclude_artifacts(&mut files, &source_exclusions, cache_relative.as_deref());
694        Ok(Self {
695            files,
696            extra_candidates: BTreeSet::new(),
697            membership: Mutex::default(),
698            membership_prefixes: Mutex::default(),
699            source_exclusions,
700            cache_relative,
701            projects: ProjectGraph::build(workspace)?,
702            roots: workspace
703                .projects
704                .values()
705                .map(|project| (project.root.clone(), project.name.clone()))
706                .collect(),
707            source_ignore: Arc::new(SourceIgnore::new(&workspace.root)?),
708            generated_tasks: graph
709                .tasks
710                .values()
711                .filter_map(|task| {
712                    let outputs = Outputs::new(workspace, task).ok()?;
713                    outputs.is_explicit().then(|| (task.clone(), outputs))
714                })
715                .collect(),
716            task_dependencies: graph
717                .tasks
718                .iter()
719                .map(|(id, task)| (id.clone(), task.dependencies.clone()))
720                .collect(),
721            canonical_root,
722            workspace_prefix,
723            patterns: Mutex::default(),
724            digests: Mutex::new(digests),
725            digests_changed: Default::default(),
726            directories: Mutex::default(),
727            runtime: Mutex::default(),
728            lockfile: Mutex::new(lockfile),
729            dependency_outputs: Mutex::default(),
730            outputs_written: Default::default(),
731        })
732    }
733
734    /// Restrict directory checks to the positive fileset prefixes a task reads.
735    fn membership_prefixes(&self, workspace: &Workspace, task: &Task) -> Result<Arc<Vec<PathBuf>>> {
736        let mut prefixes = self.membership_prefixes.lock().unwrap();
737        if let Some(prefixes) = prefixes.get(&task.id) {
738            return Ok(prefixes.clone());
739        }
740        let empty = BTreeSet::new();
741        let cancelled = AtomicBool::new(false);
742        let mut resolver = Resolver::new(self, workspace, None, &cancelled, &empty);
743        resolver.task_inputs(task)?;
744        let paths = Arc::new(
745            resolver
746                .scopes
747                .values()
748                .flat_map(|scope| &scope.patterns)
749                .filter(|pattern| !pattern.excluded)
750                .map(|pattern| workspace.root.join(&pattern.prefix))
751                .collect(),
752        );
753        prefixes.insert(task.id.clone(), Arc::clone(&paths));
754        Ok(paths)
755    }
756
757    /// Reuse membership while source directories and listing policy are unchanged.
758    /// Content changes are checked separately by the file digest cache.
759    #[cfg(test)]
760    fn current_files(&self, root: &Path) -> Result<Arc<BTreeSet<String>>> {
761        Ok(self.current_files_for(root, None, "test")?.files)
762    }
763
764    /// Refresh candidates as needed, returning their policy in the same snapshot.
765    fn current_files_for(
766        &self,
767        root: &Path,
768        prefixes: Option<&[PathBuf]>,
769        task: &str,
770    ) -> Result<Candidates> {
771        let requested = Instant::now();
772        loop {
773            let previous = self.membership.lock().unwrap().clone();
774            // Immutable entries let independent fingerprints validate their
775            // directory stamps concurrently. Only a refresh needs serialization.
776            let (unchanged, settled) = match &previous {
777                Some(entry) => entry.state(prefixes)?,
778                None => (false, false),
779            };
780            if settled && previous.as_ref().unwrap().settled {
781                return Ok(previous.as_ref().unwrap().candidates());
782            }
783            // A refresh holds this lock while it lists the workspace, so time
784            // spent here is time waiting for another task's listing.
785            let waiting = crate::profile::span(task, "inventory_wait");
786            let mut cached = self.membership.lock().unwrap();
787            drop(waiting);
788            // A listing that began after this call reflects every change made
789            // before it, so it is as current as one made now. Tasks queued
790            // behind a refresh share the next one instead of each listing the
791            // workspace in turn.
792            if let Some(entry) = &*cached
793                && entry.listed.is_some_and(|listed| listed >= requested)
794            {
795                return Ok(entry.candidates());
796            }
797            let current = match (&previous, &*cached) {
798                (Some(previous), Some(current)) => Arc::ptr_eq(previous, current),
799                (None, None) => true,
800                _ => false,
801            };
802            if !current {
803                // Another fingerprint refreshed while this one validated.
804                // Recheck that newer inventory before making any replacement.
805                continue;
806            }
807            drop(previous);
808            let _listing = crate::profile::span(task, "inventory_list");
809            let listed = Instant::now();
810            // Recent listing policies may conceal edits on coarse filesystems.
811            // Re-list sources while they settle, then rebuild the directory inventory
812            // once before enabling reuse (including any newly created empty dirs).
813            // Recent directories are checked by freshly reading their child names.
814            let rebuilt = !unchanged || settled;
815            let mut membership = if !rebuilt {
816                Arc::unwrap_or_clone(cached.take().unwrap())
817            } else {
818                // Capture directories before listing files, then compare afterward.
819                // An addition during listing must not hide behind a newer stamp.
820                Membership::new(
821                    root,
822                    cached.as_ref().map_or(&self.files, |entry| &entry.files),
823                    &self.source_ignore,
824                    &self.source_exclusions,
825                    self.cache_relative.as_deref(),
826                    cached
827                        .as_ref()
828                        .filter(|entry| unchanged && entry.settled)
829                        .map(|entry| &entry.git_policies),
830                )?
831            };
832            let mut files = source_files(root)?;
833            exclude_artifacts(
834                &mut files,
835                &self.source_exclusions,
836                self.cache_relative.as_deref(),
837            );
838            let ignore = SourceIgnore::new(root)?;
839            files.extend(
840                self.extra_candidates
841                    .iter()
842                    .filter(|path| !ignore.matches(path))
843                    .cloned(),
844            );
845            membership.source_ignore = Arc::new(ignore);
846            membership.files = Arc::new(files);
847            let candidates = membership.candidates();
848            // An unstable inventory or recent policy never enables the fast path.
849            // Keep candidates so a rebuild also watches tracked, ignored parents.
850            let covered = candidates.files.iter().all(|path| {
851                Path::new(path)
852                    .ancestors()
853                    .skip(1)
854                    .all(|parent| membership.stamps.contains_key(&root.join(parent)))
855            });
856            membership.settled = rebuilt && covered && membership.state(None)?.1;
857            membership.listed = Some(listed);
858            *cached = Some(Arc::new(membership));
859            return Ok(candidates);
860        }
861    }
862
863    /// Saves the digests of files that still exist, for the next run.
864    pub fn save_digests(&self, root: &Path) {
865        if !self
866            .digests_changed
867            .load(std::sync::atomic::Ordering::Relaxed)
868        {
869            return;
870        }
871        let digests = self.digests.lock().unwrap();
872        let entries: BTreeMap<&String, (&Stamp, &String)> = digests
873            .iter()
874            .filter(|(path, _)| self.files.contains(*path))
875            .map(|(path, (stamp, digest))| (path, (stamp, digest)))
876            .collect();
877        let path = digests_path(root);
878        let saved = (|| -> Result<()> {
879            let directory = path.parent().context("digests have a directory")?;
880            std::fs::create_dir_all(directory)?;
881            let mut file = tempfile::NamedTempFile::new_in(directory)?;
882            {
883                let mut writer = std::io::BufWriter::new(file.as_file_mut());
884                serde_json::to_writer(&mut writer, &json!({"version": 1, "entries": entries}))?;
885                writer.flush()?;
886            }
887            file.persist(&path)?;
888            Ok(())
889        })();
890        if let Err(error) = saved {
891            qk_executor::status!("qk: could not save file digests: {error:#}");
892        }
893    }
894
895    /// `paths::safe_parents`, checking each directory once per run.
896    fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
897        paths::validate_path(path)?;
898        let mut current = root.to_owned();
899        for part in Path::new(path)
900            .parent()
901            .into_iter()
902            .flat_map(Path::components)
903        {
904            current.push(part);
905            if self.directories.lock().unwrap().contains(&current) {
906                continue;
907            }
908            match std::fs::symlink_metadata(&current) {
909                Ok(metadata) if metadata.file_type().is_symlink() => {
910                    bail!("input parent is a symlink: {}", current.display())
911                }
912                Ok(metadata) if !metadata.is_dir() => {
913                    bail!("input parent is not a directory: {}", current.display())
914                }
915                Ok(_) => {
916                    self.directories.lock().unwrap().insert(current.clone());
917                }
918                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
919                Err(error) => return Err(error.into()),
920            }
921        }
922        Ok(())
923    }
924
925    fn installed(&self, root: &Path, task: &str) -> Result<Option<Arc<Installed>>> {
926        let absolute = root.join("pnpm-lock.yaml");
927        let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
928            return Ok(None);
929        };
930        if !metadata.is_file() {
931            return Ok(None);
932        }
933        let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
934        let mut current = self.lockfile.lock().unwrap();
935        if let Some(installed) = &*current
936            && installed.content == content
937        {
938            return Ok(Some(installed.clone()));
939        }
940        let parsing = crate::profile::span(task, "lockfile_parse");
941        let parsed = parse_lockfile(&absolute);
942        drop(parsing);
943        let Some(installed) = parsed else {
944            return Ok(None);
945        };
946        *current = Some(installed.clone());
947        Ok(Some(installed))
948    }
949
950    /// Adds paths to the candidate files, such as files deleted since a base
951    /// revision.
952    pub fn with_candidates(mut self, paths: &[String]) -> Self {
953        self.files.extend(
954            paths
955                .iter()
956                .filter(|path| !self.source_ignore.matches(path))
957                .cloned(),
958        );
959        self.extra_candidates.extend(paths.iter().cloned());
960        self
961    }
962
963    fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
964        let key = (pattern.to_owned(), negated);
965        if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
966            return Ok(pattern.clone());
967        }
968        let compiled = Arc::new(Pattern::new(pattern, negated)?);
969        self.patterns.lock().unwrap().insert(key, compiled.clone());
970        Ok(compiled)
971    }
972
973    fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
974        let stamp = Stamp::new(metadata);
975        if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
976            && *known == stamp
977        {
978            return Ok(digest.clone());
979        }
980        let digest = digest_file(absolute)?;
981        // Re-check after reading so a concurrent write is never recorded as settled.
982        let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
983        if after == stamp && stamp.settled() {
984            self.digests
985                .lock()
986                .unwrap()
987                .insert(path.to_owned(), (stamp, digest.clone()));
988            self.digests_changed
989                .store(true, std::sync::atomic::Ordering::Relaxed);
990        }
991        Ok(digest)
992    }
993
994    /// `task`'s output files that are not directories, as listed since the
995    /// last task that could have written them was done.
996    fn dependency_outputs(
997        &self,
998        workspace: &Workspace,
999        task: &Task,
1000    ) -> Result<Arc<DependencyOutputs>> {
1001        if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
1002            return Ok(outputs.clone());
1003        }
1004        let written = self
1005            .outputs_written
1006            .load(std::sync::atomic::Ordering::SeqCst);
1007        let outputs = Arc::new(self.fresh_dependency_outputs(workspace, task)?);
1008        let mut listed = self.dependency_outputs.lock().unwrap();
1009        if self
1010            .outputs_written
1011            .load(std::sync::atomic::Ordering::SeqCst)
1012            != written
1013        {
1014            return Ok(outputs);
1015        }
1016        Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
1017    }
1018
1019    /// Re-list artifacts for verification, without reusing earlier content values.
1020    fn fresh_dependency_outputs(
1021        &self,
1022        workspace: &Workspace,
1023        task: &Task,
1024    ) -> Result<DependencyOutputs> {
1025        let declared = Outputs::new(workspace, task)?;
1026        let mut files = Vec::new();
1027        for entry in declared.entries(&workspace.root)? {
1028            let (path, metadata) = entry?;
1029            if !metadata.is_dir() {
1030                files.push(path);
1031            }
1032        }
1033        Ok(DependencyOutputs {
1034            anchors: declared.anchors().map(str::to_owned).collect(),
1035            files,
1036            values: Mutex::default(),
1037        })
1038    }
1039
1040    /// Drops the listings `task` may have changed, once it is done. Without
1041    /// its outputs, every listing goes.
1042    pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
1043        let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
1044            .ok()
1045            .filter(Outputs::is_explicit)
1046            .map(|outputs| outputs.anchors().map(str::to_owned).collect());
1047        let overlap = |a: &str, b: &str| {
1048            a == b
1049                || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1050                || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1051        };
1052        let mut listed = self.dependency_outputs.lock().unwrap();
1053        self.outputs_written
1054            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1055        listed.retain(|_, outputs| {
1056            anchors.as_ref().is_some_and(|written| {
1057                !written
1058                    .iter()
1059                    .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
1060            })
1061        });
1062    }
1063
1064    fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
1065        self.file_value_with_links(root, path, &mut BTreeSet::new())
1066    }
1067
1068    fn file_value_with_links(
1069        &self,
1070        root: &Path,
1071        path: &str,
1072        visiting: &mut BTreeSet<PathBuf>,
1073    ) -> Result<Value> {
1074        self.safe_parents(root, path)?;
1075        let absolute = root.join(path);
1076        let metadata = std::fs::symlink_metadata(&absolute)?;
1077        if metadata.file_type().is_symlink() {
1078            let target = std::fs::read_link(&absolute)?;
1079            // A dangling link is keyed as such, so the key changes once it resolves.
1080            let Ok(resolved) = absolute.canonicalize() else {
1081                return Ok(json!({"link":target, "dangling":true}));
1082            };
1083            let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
1084                bail!("input symlink resolves outside the workspace: {path}");
1085            };
1086            if resolved.is_file() {
1087                let metadata = std::fs::metadata(&resolved)?;
1088                let key = paths::relative(Path::new(""), relative)?;
1089                return Ok(
1090                    json!({"link":target, "content":self.digest(&key, &resolved, &metadata)?,
1091                    "mode":crate::store::mode(&metadata) & 0o111 != 0}),
1092                );
1093            }
1094            let relative = paths::relative(Path::new(""), relative)?;
1095            if relative.is_empty() {
1096                bail!("input symlink resolves to the workspace root: {path}");
1097            }
1098            if !visiting.insert(resolved.clone()) {
1099                bail!("directory input symlink cycle: {path}");
1100            }
1101            let mut contents = BTreeMap::new();
1102            // Selecting the link explicitly reads its target, even when source
1103            // discovery ignores that target or another link is nested inside it.
1104            for entry in walkdir::WalkDir::new(&resolved).follow_links(false) {
1105                let entry = entry?;
1106                if entry.file_type().is_dir() {
1107                    continue;
1108                }
1109                let file = paths::relative(&self.canonical_root, entry.path())?;
1110                let value = self.file_value_with_links(&self.canonical_root, &file, visiting)?;
1111                contents.insert(
1112                    file.strip_prefix(&relative).unwrap_or(&file).to_owned(),
1113                    value,
1114                );
1115            }
1116            visiting.remove(&resolved);
1117            return Ok(json!({"link":target, "directory":contents}));
1118        }
1119        if !metadata.is_file() {
1120            bail!("input is not a regular file: {path}");
1121        }
1122        // Only the executable bit, which is all git records: other permission
1123        // bits follow the checkout's umask and would keep machines apart.
1124        let mode = crate::store::mode(&metadata) & 0o111 != 0;
1125        Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
1126    }
1127}
1128
1129/// Reads and parses a pnpm lockfile, `None` when there is none. One qk cannot
1130/// read is reported, and kept as unreadable so tasks key its whole content.
1131fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
1132    let text = std::fs::read_to_string(path).ok()?;
1133    let lockfile = match Lockfile::parse(&text) {
1134        Ok(lockfile) => Some(lockfile),
1135        Err(error) => {
1136            qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
1137            None
1138        }
1139    };
1140    Some(Arc::new(Installed {
1141        content: blake3::hash(text.as_bytes()).to_hex().to_string(),
1142        lockfile,
1143        digests: Mutex::default(),
1144    }))
1145}
1146
1147fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
1148    #[derive(serde::Deserialize)]
1149    struct Saved {
1150        version: u32,
1151        entries: HashMap<String, (Stamp, String)>,
1152    }
1153    std::fs::read(digests_path(root))
1154        .ok()
1155        .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
1156        .filter(|saved| saved.version == 1)
1157        .map(|saved| saved.entries)
1158        .unwrap_or_default()
1159}
1160
1161/// Apply the same source policy to startup and refreshed file membership.
1162/// Remove declared artifacts and cache storage from a refreshed source listing.
1163fn exclude_artifacts(files: &mut BTreeSet<String>, outputs: &[Outputs], cache: Option<&Path>) {
1164    let mut generated = BTreeSet::new();
1165    for outputs in outputs {
1166        for anchor in outputs.anchors() {
1167            generated.extend(
1168                under(files, anchor)
1169                    .filter(|path| outputs.matches(path))
1170                    .cloned(),
1171            );
1172        }
1173    }
1174    files.retain(|path| {
1175        !generated.contains(path) && cache.is_none_or(|cache| !Path::new(path).starts_with(cache))
1176    });
1177}
1178
1179/// Files equal to `prefix` or below it, using the sorted order of the set.
1180impl Snapshot {
1181    /// The project whose root most specifically contains `path`, as Nx assigns
1182    /// files to projects.
1183    fn owner(&self, path: &str) -> Option<&str> {
1184        let mut path = path;
1185        loop {
1186            if let Some(project) = self.roots.get(path) {
1187                return Some(project);
1188            }
1189            match path.rfind('/') {
1190                Some(slash) => path = &path[..slash],
1191                None if path != "." => path = ".",
1192                None => return None,
1193            }
1194        }
1195    }
1196}
1197
1198fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
1199    files
1200        .range::<str, _>((
1201            std::ops::Bound::Included(prefix),
1202            std::ops::Bound::Unbounded,
1203        ))
1204        .take_while(move |path| path.starts_with(prefix))
1205        .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
1206}
1207
1208/// Compiled root ignore rules used by input discovery and project watching.
1209pub struct SourceIgnore(ignore::gitignore::Gitignore);
1210
1211impl SourceIgnore {
1212    /// Root ignore rules shared by caching and watch, including tracked files.
1213    pub fn new(root: &Path) -> Result<Self> {
1214        let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
1215        for name in [".gitignore", ".nxignore"] {
1216            let path = root.join(name);
1217            if path.is_file()
1218                && let Some(error) = builder.add(path)
1219            {
1220                return Err(error.into());
1221            }
1222        }
1223        Ok(Self(builder.build()?))
1224    }
1225
1226    fn matches_directory(&self, path: &str) -> bool {
1227        self.matches(path) || self.0.matched(path, true).is_ignore()
1228    }
1229
1230    /// Whether a workspace-relative file or its parent is ignored.
1231    pub fn matches(&self, path: &str) -> bool {
1232        // A file whitelist cannot reinclude it beneath an excluded directory.
1233        Path::new(path)
1234            .ancestors()
1235            .skip(1)
1236            .take_while(|parent| !parent.as_os_str().is_empty())
1237            .any(|parent| self.0.matched(parent, true).is_ignore())
1238            || self.0.matched(path, false).is_ignore()
1239    }
1240}
1241
1242/// Source files visible to Nx, with runner state and root ignore rules removed.
1243pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
1244    let lines =
1245        |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
1246            let output = output.ok().filter(|output| output.status.success())?;
1247            Some(
1248                output
1249                    .stdout
1250                    .split(|byte| *byte == 0)
1251                    .filter(|path| !path.is_empty())
1252                    .map(|path| {
1253                        String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
1254                    })
1255                    .collect(),
1256            )
1257        };
1258    // Tracked files deleted from the working tree are listed as cached; git
1259    // names them in a second listing, which is cheaper than checking each file.
1260    // Skip-worktree entries are listed whether or not they are on disk and never
1261    // as deleted, so those few are checked; `-v` tags them `S`.
1262    let (listed, deleted) = if root.join(".nxignore").is_file() {
1263        // Nx negations can reinclude Git-ignored files, so this policy needs
1264        // the walker regardless of what Git lists. Avoid launching Git here.
1265        (None, None)
1266    } else {
1267        std::thread::scope(|scope| {
1268            let deleted =
1269                scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
1270            let listed = paths::git(
1271                root,
1272                &[
1273                    "ls-files",
1274                    "-v",
1275                    "--cached",
1276                    "--others",
1277                    "--exclude-standard",
1278                    "-z",
1279                    "--",
1280                    ".",
1281                ],
1282            );
1283            (
1284                lines(listed),
1285                lines(deleted.join().expect("git listing thread panicked")),
1286            )
1287        })
1288    };
1289    let candidates = match (listed, deleted) {
1290        (Some(listed), Some(deleted)) => {
1291            let deleted = deleted?;
1292            let mut files = BTreeSet::new();
1293            for entry in listed? {
1294                let Some((tag, path)) = entry.split_once(' ') else {
1295                    bail!("unexpected git ls-files entry {entry:?}");
1296                };
1297                let absent =
1298                    matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
1299                if !absent && !deleted.contains(path) {
1300                    files.insert(path.to_owned());
1301                }
1302            }
1303            files
1304        }
1305        _ => {
1306            let mut files = BTreeSet::new();
1307            for entry in ignore::WalkBuilder::new(root)
1308                .hidden(false)
1309                .parents(false)
1310                .git_global(false)
1311                .require_git(false)
1312                .ignore(false)
1313                .add_custom_ignore_filename(".nxignore")
1314                .follow_links(false)
1315                .filter_entry(|entry| {
1316                    !matches!(
1317                        entry.file_name().to_str(),
1318                        Some(".git" | ".qk" | "node_modules")
1319                    )
1320                })
1321                .build()
1322            {
1323                let entry = entry?;
1324                if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
1325                    files.insert(paths::relative(root, entry.path())?);
1326                }
1327            }
1328            files
1329        }
1330    };
1331    let ignore = SourceIgnore::new(root)?;
1332    Ok(candidates
1333        .into_iter()
1334        .filter(|path| {
1335            !ignore.matches(path)
1336                && !path
1337                    .split('/')
1338                    .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
1339        })
1340        .collect())
1341}
1342
1343fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
1344    path.split('.')
1345        .try_fold(value, |value, part| value.get(part))
1346}
1347
1348fn set_json_path(target: &mut Value, path: &str, value: Value) {
1349    let mut parts = path.split('.').peekable();
1350    let mut current = target;
1351    while let Some(part) = parts.next() {
1352        let Value::Object(object) = current else {
1353            return;
1354        };
1355        if parts.peek().is_none() {
1356            object.insert(part.to_owned(), value);
1357            return;
1358        }
1359        current = object.entry(part).or_insert_with(|| json!({}));
1360    }
1361}
1362
1363fn remove_json_path(target: &mut Value, path: &str) {
1364    let (parent, last) = match path.rsplit_once('.') {
1365        Some((parent, last)) => (Some(parent), last),
1366        None => (None, path),
1367    };
1368    let parent = match parent {
1369        Some(parent) => parent
1370            .split('.')
1371            .try_fold(&mut *target, |value, part| value.get_mut(part)),
1372        None => Some(target),
1373    };
1374    if let Some(Value::Object(object)) = parent {
1375        object.remove(last);
1376    }
1377}
1378
1379struct FilePattern {
1380    matcher: Arc<Pattern>,
1381    prefix: String,
1382    excluded: bool,
1383    /// The project a project fileset selects the files of.
1384    owner: Option<String>,
1385}
1386
1387#[derive(Default)]
1388struct FileSelection {
1389    patterns: Vec<FilePattern>,
1390    included: BTreeSet<String>,
1391    excluded: BTreeSet<String>,
1392}
1393
1394#[derive(PartialEq, Eq, PartialOrd, Ord)]
1395enum FileScope {
1396    Workspace,
1397    Project(String),
1398}
1399
1400struct Resolver<'a> {
1401    workspace: &'a Workspace,
1402    snapshot: &'a Snapshot,
1403    candidates: &'a BTreeSet<String>,
1404    selected: BTreeSet<String>,
1405    scopes: BTreeMap<FileScope, FileSelection>,
1406    values: BTreeMap<String, Value>,
1407    named_stack: Vec<(String, String)>,
1408    /// Dependency named inputs already expanded for `^` inputs.
1409    expanded: BTreeSet<(String, String)>,
1410    external: BTreeSet<String>,
1411    /// `None` resolves which inputs a task has without evaluating env or
1412    /// runtime inputs.
1413    prepared: Option<&'a PreparedTask>,
1414    cancelled: &'a AtomicBool,
1415}
1416
1417impl<'a> Resolver<'a> {
1418    fn new(
1419        snapshot: &'a Snapshot,
1420        workspace: &'a Workspace,
1421        prepared: Option<&'a PreparedTask>,
1422        cancelled: &'a AtomicBool,
1423        candidates: &'a BTreeSet<String>,
1424    ) -> Self {
1425        Self {
1426            workspace,
1427            snapshot,
1428            candidates,
1429            selected: BTreeSet::new(),
1430            scopes: BTreeMap::new(),
1431            values: BTreeMap::new(),
1432            named_stack: Vec::new(),
1433            expanded: BTreeSet::new(),
1434            external: BTreeSet::new(),
1435            prepared,
1436            cancelled,
1437        }
1438    }
1439
1440    fn task_inputs(&mut self, task: &Task) -> Result<()> {
1441        let default = vec![json!("default"), json!("^default")];
1442        for input in task.definition.inputs.as_ref().unwrap_or(&default) {
1443            self.input(&task.project, input)?;
1444        }
1445        Ok(())
1446    }
1447
1448    fn input(&mut self, project: &str, input: &Value) -> Result<()> {
1449        match input {
1450            // As in Nx, `^name` is `name` of every project the project depends
1451            // on, directly or not.
1452            Value::String(name) if name.starts_with('^') => {
1453                self.dependencies_named(project, &name[1..])?;
1454            }
1455            // The object spellings of named inputs, as Nx reads them.
1456            Value::Object(object)
1457                if object.contains_key("input") && !object.contains_key("fileset") =>
1458            {
1459                let name = object["input"]
1460                    .as_str()
1461                    .context("input must name a named input")?;
1462                let projects = object.get("projects");
1463                let dependencies = object.get("dependencies") == Some(&json!(true))
1464                    || projects == Some(&json!("dependencies"));
1465                let listed = projects.filter(|projects| {
1466                    **projects != json!("self") && **projects != json!("dependencies")
1467                });
1468                if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
1469                    bail!("named inputs can only refer to named inputs of their own project");
1470                }
1471                if dependencies {
1472                    self.dependencies_named(project, name)?;
1473                } else if let Some(listed) = listed {
1474                    let patterns: Vec<String> = match listed {
1475                        Value::String(pattern) => vec![pattern.clone()],
1476                        other => serde_json::from_value(other.clone())
1477                            .context("input projects must be a string or an array")?,
1478                    };
1479                    for selected in
1480                        qk_graph::select_projects(&self.workspace.projects, &patterns, &[])?
1481                    {
1482                        if self.expanded.insert((selected.clone(), name.to_owned())) {
1483                            self.named(&selected, name)?;
1484                        }
1485                    }
1486                } else {
1487                    self.named(project, name)?;
1488                }
1489            }
1490            Value::Object(object)
1491                if object.contains_key("fileset")
1492                    && object.get("dependencies") == Some(&json!(true)) =>
1493            {
1494                if !self.named_stack.is_empty() {
1495                    bail!("named inputs can only refer to named inputs of their own project");
1496                }
1497                let pattern = object["fileset"]
1498                    .as_str()
1499                    .context("fileset input must be a glob")?
1500                    .to_owned();
1501                for dependency in self.dependency_closure(project) {
1502                    self.fileset(&dependency, &pattern)?;
1503                }
1504            }
1505            Value::Object(object)
1506                if object.len() == 1 && object.contains_key("workingDirectory") =>
1507            {
1508                let mode = object["workingDirectory"].as_str().unwrap_or_default();
1509                let directory = std::env::current_dir()?;
1510                let value = match mode {
1511                    "absolute" => directory.to_string_lossy().into_owned(),
1512                    "relative" => {
1513                        let root = self.workspace.root.canonicalize()?;
1514                        let directory = directory.canonicalize()?;
1515                        paths::relative(&root, &directory)
1516                            .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
1517                    }
1518                    _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
1519                };
1520                self.values
1521                    .insert(format!("workingDirectory:{mode}"), json!(value));
1522            }
1523            Value::Object(object) if object.contains_key("json") => {
1524                self.json(project, object)?;
1525            }
1526            Value::String(name)
1527                if !name.contains("{projectRoot}") && !name.contains("{workspaceRoot}") =>
1528            {
1529                self.named(project, name)?
1530            }
1531            Value::Object(object) if object.len() == 1 && object.contains_key("fileset") => {
1532                let pattern = object["fileset"]
1533                    .as_str()
1534                    .context("fileset input must be a glob")?;
1535                self.fileset(project, pattern)?;
1536            }
1537            Value::String(pattern) => self.fileset(project, pattern)?,
1538            Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
1539                let name = object["env"]
1540                    .as_str()
1541                    .context("env input must name a variable")?;
1542                let Some(prepared) = self.prepared else {
1543                    self.values.insert(format!("env:{name}"), Value::Null);
1544                    return Ok(());
1545                };
1546                let value = prepared
1547                    .env
1548                    .get(std::ffi::OsStr::new(name))
1549                    .map(|value| {
1550                        value
1551                            .to_str()
1552                            .map(str::to_owned)
1553                            .context("declared env input must be UTF-8")
1554                    })
1555                    .transpose()?;
1556                self.values.insert(format!("env:{name}"), json!(value));
1557            }
1558            Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
1559                let command = object["runtime"]
1560                    .as_str()
1561                    .context("runtime input must be a command")?;
1562                let Some(prepared) = self.prepared else {
1563                    self.values
1564                        .insert(format!("runtime:{command}"), Value::Null);
1565                    return Ok(());
1566                };
1567                let memo = RuntimeKey {
1568                    command: command.into(),
1569                    environment: prepared.runtime_env.clone(),
1570                };
1571                let slot = self
1572                    .snapshot
1573                    .runtime
1574                    .lock()
1575                    .unwrap()
1576                    .entry(memo)
1577                    .or_default()
1578                    .clone();
1579                let mut saved = slot.lock().unwrap();
1580                if let Some(value) = saved.as_ref() {
1581                    self.values
1582                        .insert(format!("runtime:{command}"), value.clone());
1583                    return Ok(());
1584                }
1585                let _profile = crate::profile::span(command, "runtime");
1586                let mut prepared = prepared.clone();
1587                prepared.env = prepared.runtime_env.clone();
1588                prepared.execution.clear();
1589                prepared.node_path = None;
1590                prepared.commands = vec![command.into()];
1591                prepared.cwd = self.workspace.root.clone();
1592                prepared.parallel = false;
1593                let log = tempfile::NamedTempFile::new()?;
1594                let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
1595                if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
1596                {
1597                    bail!("runtime input did not succeed");
1598                }
1599                let mut stdout = blake3::Hasher::new();
1600                let mut stderr = blake3::Hasher::new();
1601                read_capture(File::open(log.path())?, |error, bytes| {
1602                    if error {
1603                        stderr.update(bytes);
1604                    } else {
1605                        stdout.update(bytes);
1606                    }
1607                    Ok(())
1608                })?;
1609                let value = json!([
1610                    stdout.finalize().to_hex().to_string(),
1611                    stderr.finalize().to_hex().to_string()
1612                ]);
1613                *saved = Some(value.clone());
1614                self.values.insert(format!("runtime:{command}"), value);
1615            }
1616            Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
1617                if object
1618                    .keys()
1619                    .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
1620                {
1621                    bail!("unsupported output input field");
1622                }
1623                let pattern = object["dependentTasksOutputFiles"]
1624                    .as_str()
1625                    .context("dependentTasksOutputFiles must be a glob")?;
1626                self.snapshot.pattern(pattern, false)?;
1627                let transitive = object
1628                    .get("transitive")
1629                    .map(|value| value.as_bool().context("transitive must be boolean"))
1630                    .transpose()?
1631                    .unwrap_or(false);
1632                // Resolved before execution too, so inspection can display the
1633                // selection without reading generated files. dependency_keys
1634                // hashes these files after the dependencies finish.
1635                let value = self
1636                    .values
1637                    .entry(format!("dependentTasksOutputFiles:{pattern}"))
1638                    .or_insert(json!(false));
1639                if transitive {
1640                    *value = json!(true);
1641                }
1642            }
1643            Value::Object(object)
1644                if object.len() == 1 && object.contains_key("externalDependencies") =>
1645            {
1646                let names: Vec<String> =
1647                    serde_json::from_value(object["externalDependencies"].clone())?;
1648                self.external.extend(names);
1649            }
1650            _ => bail!("unsupported task input declaration"),
1651        }
1652        Ok(())
1653    }
1654
1655    fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
1656        let (exclude, pattern) = pattern
1657            .strip_prefix('!')
1658            .map(|pattern| (true, pattern))
1659            .unwrap_or((false, pattern));
1660        // Nx hashes workspace files and each project's files in separate
1661        // scopes, a project's being only the files it owns: those of a project
1662        // nested in its root are that project's.
1663        let (scope, owner) = if pattern.contains("{workspaceRoot}") {
1664            (FileScope::Workspace, None)
1665        } else {
1666            (
1667                FileScope::Project(project.to_owned()),
1668                Some(project.to_owned()),
1669            )
1670        };
1671        let pattern = paths::expand(self.workspace, project, pattern)?;
1672        let matcher = self.snapshot.pattern(&pattern, exclude)?;
1673        let prefix = pattern
1674            .split('/')
1675            .take_while(|part| is_literal(part))
1676            .collect::<Vec<_>>()
1677            .join("/");
1678        let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
1679            Box::new(self.candidates.iter())
1680        } else {
1681            Box::new(under(self.candidates, &prefix))
1682        };
1683        let snapshot = self.snapshot;
1684        let selection = self.scopes.entry(scope).or_default();
1685        let selected = if exclude {
1686            &mut selection.excluded
1687        } else {
1688            &mut selection.included
1689        };
1690        selected.extend(
1691            candidates
1692                .filter(|file| matcher.is_match(file))
1693                .filter(|file| owner.is_none() || snapshot.owner(file) == owner.as_deref())
1694                .cloned(),
1695        );
1696        selection.patterns.push(FilePattern {
1697            matcher,
1698            prefix,
1699            excluded: exclude,
1700            owner,
1701        });
1702        Ok(())
1703    }
1704
1705    /// Every project `project` depends on, directly or not.
1706    fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
1707        let mut closure = BTreeSet::new();
1708        let mut pending = vec![project.to_owned()];
1709        while let Some(current) = pending.pop() {
1710            for edge in &self.snapshot.projects.dependencies[&current] {
1711                if edge.target != project && closure.insert(edge.target.clone()) {
1712                    pending.push(edge.target.clone());
1713                }
1714            }
1715        }
1716        closure
1717    }
1718
1719    /// As in Nx, `^name` is `name` of every project the project depends on.
1720    fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
1721        for dependency in self.dependency_closure(project) {
1722            if self.expanded.insert((dependency.clone(), name.to_owned())) {
1723                self.named(&dependency, name)?;
1724            }
1725        }
1726        Ok(())
1727    }
1728
1729    /// A JSON input: the fields of one JSON file, rather than all of it.
1730    /// `fields` keeps only the named dotted paths; `excludeFields` removes
1731    /// them.
1732    fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
1733        for key in object.keys() {
1734            if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
1735                bail!("unsupported json input field {key:?}");
1736            }
1737        }
1738        let path = object["json"]
1739            .as_str()
1740            .context("json input must name a file")?;
1741        let path = paths::expand(self.workspace, project, path)?;
1742        paths::validate_path(&path)?;
1743        let list = |name: &str| -> Result<Option<Vec<String>>> {
1744            object
1745                .get(name)
1746                .map(|value| {
1747                    serde_json::from_value(value.clone())
1748                        .with_context(|| format!("json input {name} must be an array of strings"))
1749                })
1750                .transpose()
1751        };
1752        let fields = list("fields")?;
1753        let exclude = list("excludeFields")?.unwrap_or_default();
1754        let value = match std::fs::read_to_string(self.workspace.root.join(&path)) {
1755            Ok(text) => {
1756                let mut value: Value =
1757                    jsonc_parser::parse_to_serde_value(&text, &Default::default())
1758                        .with_context(|| format!("cannot parse json input {path}"))?;
1759                if let Some(fields) = &fields {
1760                    let mut kept = json!({});
1761                    for field in fields {
1762                        if let Some(found) = json_path(&value, field) {
1763                            set_json_path(&mut kept, field, found.clone());
1764                        }
1765                    }
1766                    value = kept;
1767                }
1768                for field in &exclude {
1769                    remove_json_path(&mut value, field);
1770                }
1771                value
1772            }
1773            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Null,
1774            Err(error) => return Err(error.into()),
1775        };
1776        self.values.insert(format!("json:{path}"), value);
1777        Ok(())
1778    }
1779
1780    fn named(&mut self, project: &str, name: &str) -> Result<()> {
1781        let identity = (project.to_owned(), name.to_owned());
1782        if self.named_stack.contains(&identity) {
1783            bail!("named input cycle at {project}:{name}");
1784        }
1785        self.named_stack.push(identity);
1786        let inputs = self.workspace.projects[project]
1787            .named_inputs
1788            .get(name)
1789            .cloned()
1790            .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
1791            .with_context(|| format!("unknown named input {name:?}"))?;
1792        for input in &inputs {
1793            self.input(project, input)?;
1794        }
1795        self.named_stack.pop();
1796        Ok(())
1797    }
1798}
1799
1800pub fn resolve(
1801    snapshot: &Snapshot,
1802    workspace: &Workspace,
1803    task: &Task,
1804    prepared: Option<&PreparedTask>,
1805    cancelled: &AtomicBool,
1806) -> Result<Resolved> {
1807    resolve_candidates(
1808        snapshot,
1809        workspace,
1810        task,
1811        prepared,
1812        cancelled,
1813        &snapshot.files,
1814        &snapshot.source_ignore,
1815    )
1816}
1817
1818fn resolve_candidates(
1819    snapshot: &Snapshot,
1820    workspace: &Workspace,
1821    task: &Task,
1822    prepared: Option<&PreparedTask>,
1823    cancelled: &AtomicBool,
1824    candidates: &BTreeSet<String>,
1825    source_ignore: &SourceIgnore,
1826) -> Result<Resolved> {
1827    let mut resolver = Resolver::new(snapshot, workspace, prepared, cancelled, candidates);
1828    resolver.task_inputs(task)?;
1829    // Dependency outputs may also be declared sources. Downstream and unrelated
1830    // outputs remain artifacts, so producing them cannot invalidate an upstream task.
1831    let mut upstream = BTreeSet::new();
1832    let mut pending: Vec<_> = task.dependencies.iter().cloned().collect();
1833    while let Some(id) = pending.pop() {
1834        if upstream.insert(id.clone())
1835            && let Some(dependencies) = snapshot.task_dependencies.get(&id)
1836        {
1837            pending.extend(dependencies.iter().cloned());
1838        }
1839    }
1840    let own_outputs = Outputs::new(workspace, task)
1841        .ok()
1842        .filter(Outputs::is_explicit);
1843    for (producer, declared) in &snapshot.generated_tasks {
1844        if producer.id == task.id || !upstream.contains(&producer.id) {
1845            continue;
1846        }
1847        let overlaps = |a: &str, b: &str| {
1848            a.is_empty()
1849                || a == b
1850                || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1851                || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1852        };
1853        let relevant = resolver
1854            .scopes
1855            .values()
1856            .flat_map(|scope| &scope.patterns)
1857            .filter(|pattern| !pattern.excluded)
1858            .any(|pattern| {
1859                declared.anchors().any(|anchor| {
1860                    overlaps(&pattern.prefix, anchor)
1861                        && (!source_ignore.matches_directory(anchor)
1862                            || !workspace.root.join(anchor).is_dir())
1863                })
1864            });
1865        if !relevant {
1866            continue;
1867        }
1868        let outputs = snapshot.dependency_outputs(workspace, producer)?;
1869        for path in &outputs.files {
1870            if source_ignore.matches(path)
1871                || own_outputs.as_ref().is_some_and(|own| own.matches(path))
1872            {
1873                continue;
1874            }
1875            for selection in resolver.scopes.values_mut() {
1876                for pattern in &selection.patterns {
1877                    if pattern.matcher.is_match(path)
1878                        && (pattern.owner.is_none()
1879                            || snapshot.owner(path) == pattern.owner.as_deref())
1880                    {
1881                        if pattern.excluded {
1882                            selection.excluded.insert(path.clone());
1883                        } else {
1884                            selection.included.insert(path.clone());
1885                        }
1886                    }
1887                }
1888            }
1889        }
1890    }
1891    for selection in resolver.scopes.values() {
1892        resolver.selected.extend(
1893            selection
1894                .included
1895                .difference(&selection.excluded)
1896                .filter(|path| own_outputs.as_ref().is_none_or(|own| !own.matches(path)))
1897                .cloned(),
1898        );
1899    }
1900    let readable = snapshot
1901        .installed(&workspace.root, &task.id)?
1902        .is_some_and(|installed| installed.lockfile.is_some());
1903    // Always include workspace resolution and configuration. Dotenv files are
1904    // not keyed, as in Nx: they hold per-machine values and credentials, and a
1905    // task's `env` inputs key the variables it declares.
1906    // A pnpm lockfile qk can read is keyed by what the task's projects install instead.
1907    // The root tsconfig, as Nx hashes it into every task. The root package.json
1908    // is not, as in Nx: what the root importer installs counts for every task
1909    // through the lockfile, and the rest of it is the root project's manifest.
1910    let tsconfig = ["tsconfig.base.json", "tsconfig.json"]
1911        .into_iter()
1912        .find(|name| {
1913            workspace.root.join(name).is_file() || snapshot.extra_candidates.contains(*name)
1914        });
1915    let mut mandatory = BTreeSet::new();
1916    for path in tsconfig.into_iter().chain([
1917        "nx.json",
1918        ".gitignore",
1919        ".nxignore",
1920        qk_config::LOCAL_WORKSPACE,
1921        "pnpm-workspace.yaml",
1922        "pnpm-lock.yaml",
1923        "package-lock.json",
1924        "yarn.lock",
1925        "bun.lock",
1926    ]) {
1927        if (workspace.root.join(path).is_file() || snapshot.extra_candidates.contains(path))
1928            && !(path == "pnpm-lock.yaml" && readable)
1929        {
1930            resolver.selected.insert(path.into());
1931            mandatory.insert(path.into());
1932        }
1933    }
1934    if let Some(extended) = &workspace.extended {
1935        resolver.selected.insert(extended.clone());
1936        mandatory.insert(extended.clone());
1937    }
1938    // The task's own manifests remain inputs even when filesets exclude them,
1939    // its package scripts and dependency declarations with them. A dependency's
1940    // package.json counts by what it decides for dependents, such as where
1941    // their imports of it resolve; its installs count through the lockfile.
1942    let mut packages = BTreeSet::from([task.project.clone()]);
1943    let mut pending = packages.clone();
1944    while let Some(project) = pending.pop_first() {
1945        for edge in &snapshot.projects.dependencies[&project] {
1946            if packages.insert(edge.target.clone()) {
1947                pending.insert(edge.target.clone());
1948            }
1949        }
1950    }
1951    for project in &packages {
1952        let own = *project == task.project;
1953        for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
1954            if !own && name != "package.json" {
1955                continue;
1956            }
1957            let path = Path::new(&workspace.projects[project].root).join(name);
1958            let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
1959            if !workspace.root.join(&path).is_file() {
1960                continue;
1961            }
1962            let path = path
1963                .to_str()
1964                .context("project path must be UTF-8")?
1965                .replace('\\', "/");
1966            mandatory.insert(path.clone());
1967            // A manifest the task's inputs name counts whole, as they say.
1968            if own || resolver.selected.contains(&path) {
1969                resolver.selected.insert(path);
1970                continue;
1971            }
1972            let text = std::fs::read_to_string(workspace.root.join(&path))
1973                .with_context(|| format!("cannot read {path}"))?;
1974            match manifest_for_dependents(&text, readable) {
1975                Some(value) => {
1976                    resolver.values.insert(format!("manifest:{path}"), value);
1977                }
1978                None => {
1979                    resolver.selected.insert(path);
1980                }
1981            }
1982        }
1983    }
1984    // However it was selected, the workspace file's resolution keys reach the
1985    // task through the lockfile when qk can read it.
1986    let workspace_file = readable && resolver.selected.remove("pnpm-workspace.yaml");
1987    if workspace_file {
1988        mandatory.remove("pnpm-workspace.yaml");
1989    }
1990    let lockfile = readable.then(|| {
1991        // The root importer's packages resolve from every package in the
1992        // workspace, so they count for every task.
1993        let importers = std::iter::once(".".to_owned())
1994            .chain(
1995                packages
1996                    .iter()
1997                    .map(|project| workspace.projects[project].root.clone()),
1998            )
1999            .collect();
2000        (importers, resolver.external.clone())
2001    });
2002    Ok(Resolved {
2003        files: resolver.selected,
2004        mandatory,
2005        values: resolver.values,
2006        lockfile,
2007        workspace_file,
2008        external: resolver.external,
2009    })
2010}
2011
2012/// What a task's key is computed from, as JSON: kept by the history so a
2013/// changed key can be explained.
2014pub fn inputs(
2015    snapshot: &Snapshot,
2016    workspace: &Workspace,
2017    task: &Task,
2018    prepared: &PreparedTask,
2019    dependencies: &BTreeMap<String, String>,
2020    cancelled: &AtomicBool,
2021) -> Result<Value> {
2022    let prefixes = snapshot.membership_prefixes(workspace, task)?;
2023    let candidates = snapshot.current_files_for(&workspace.root, Some(&prefixes), &task.id)?;
2024    let Resolved {
2025        files,
2026        mut values,
2027        lockfile,
2028        workspace_file,
2029        ..
2030    } = resolve_candidates(
2031        snapshot,
2032        workspace,
2033        task,
2034        Some(prepared),
2035        cancelled,
2036        &candidates.files,
2037        &candidates.source_ignore,
2038    )?;
2039    let installed = snapshot.installed(&workspace.root, &task.id)?;
2040    if let (Some((importers, external)), Some(installed)) = (lockfile, &installed)
2041        && let Some(lockfile) = &installed.lockfile
2042    {
2043        let importers: BTreeMap<_, _> = importers
2044            .iter()
2045            .filter(|importer| lockfile.has_importer(importer))
2046            .map(|importer| {
2047                let digest = installed.digest(&format!("importer:{importer}"), || {
2048                    lockfile.installed(importer).unwrap_or_default()
2049                });
2050                (importer.clone(), digest)
2051            })
2052            .collect();
2053        let external: BTreeMap<_, _> = external
2054            .iter()
2055            .map(|name| {
2056                let digest =
2057                    installed.digest(&format!("package:{name}"), || lockfile.package(name));
2058                (name.clone(), digest)
2059            })
2060            .collect();
2061        values.insert(
2062            "lockfile".into(),
2063            json!({
2064                "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
2065                "importers": importers,
2066                "external": external,
2067            }),
2068        );
2069    }
2070    let mut files = files
2071        .iter()
2072        .map(|path| {
2073            let value = snapshot
2074                .file_value(&workspace.root, path)
2075                .with_context(|| format!("cannot read input {path}"))?;
2076            Ok((path.clone(), value))
2077        })
2078        .collect::<Result<BTreeMap<_, _>>>()?;
2079    if workspace_file {
2080        match workspace_without_resolution(&workspace.root) {
2081            Some(value) => {
2082                values.insert("pnpm-workspace".into(), value);
2083            }
2084            None => {
2085                files.insert(
2086                    "pnpm-workspace.yaml".into(),
2087                    snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
2088                );
2089            }
2090        }
2091    }
2092    // Warm state configuration never changes a result.
2093    let mut definition = task.definition.clone();
2094    definition.extra.remove("qk:warm");
2095    definition.extra.remove("qk:threads");
2096    let hash = json!({
2097        "schema":"qk-local-v2", "qk":env!("CARGO_PKG_VERSION"),
2098        "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
2099        "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
2100        "files":files, "values":values, "dependencies":dependencies,
2101    });
2102    Ok(hash)
2103}
2104
2105/// The key for a task's inputs.
2106pub fn key(inputs: &Value) -> Result<String> {
2107    Ok(blake3::hash(&serde_json::to_vec(inputs)?)
2108        .to_hex()
2109        .to_string())
2110}
2111
2112pub fn fingerprint(
2113    snapshot: &Snapshot,
2114    workspace: &Workspace,
2115    task: &Task,
2116    prepared: &PreparedTask,
2117    dependencies: &BTreeMap<String, String>,
2118    cancelled: &AtomicBool,
2119) -> Result<String> {
2120    key(&inputs(
2121        snapshot,
2122        workspace,
2123        task,
2124        prepared,
2125        dependencies,
2126        cancelled,
2127    )?)
2128}
2129
2130/// Use selected dependency artifacts when output inputs are declared; otherwise
2131/// preserve the normal dependency fingerprints. All dependencies must have
2132/// succeeded and be fingerprintable before this selection is applied.
2133pub(crate) fn dependency_keys(
2134    snapshot: &Snapshot,
2135    workspace: &Workspace,
2136    graph: &TaskGraph,
2137    task: &Task,
2138    dependencies: &BTreeMap<String, String>,
2139    cancelled: &AtomicBool,
2140) -> Result<BTreeMap<String, String>> {
2141    dependency_keys_impl(
2142        snapshot,
2143        workspace,
2144        graph,
2145        task,
2146        dependencies,
2147        cancelled,
2148        false,
2149    )
2150}
2151
2152/// Read dependency artifacts again before accepting an execution as unchanged.
2153pub(crate) fn recheck_dependency_keys(
2154    snapshot: &Snapshot,
2155    workspace: &Workspace,
2156    graph: &TaskGraph,
2157    task: &Task,
2158    dependencies: &BTreeMap<String, String>,
2159    cancelled: &AtomicBool,
2160) -> Result<BTreeMap<String, String>> {
2161    dependency_keys_impl(
2162        snapshot,
2163        workspace,
2164        graph,
2165        task,
2166        dependencies,
2167        cancelled,
2168        true,
2169    )
2170}
2171
2172fn dependency_keys_impl(
2173    snapshot: &Snapshot,
2174    workspace: &Workspace,
2175    graph: &TaskGraph,
2176    task: &Task,
2177    dependencies: &BTreeMap<String, String>,
2178    cancelled: &AtomicBool,
2179    fresh: bool,
2180) -> Result<BTreeMap<String, String>> {
2181    let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
2182    let selections: Vec<_> = resolved.dependency_output_inputs().collect();
2183    if selections.is_empty() {
2184        if fresh {
2185            return dependencies
2186                .iter()
2187                .map(|(id, key)| {
2188                    let outputs = Outputs::new(workspace, &graph.tasks[id])?;
2189                    let key = if outputs.declared() {
2190                        crate::output_fingerprint(&workspace.root, &outputs, "")?
2191                    } else {
2192                        key.clone()
2193                    };
2194                    Ok((id.clone(), key))
2195                })
2196                .collect();
2197        }
2198        return Ok(dependencies.clone());
2199    }
2200    let mut keys = BTreeMap::new();
2201    let mut fresh_outputs = HashMap::new();
2202    for (pattern, transitive) in selections {
2203        let matcher = snapshot.pattern(pattern, false)?;
2204        let mut selected = task.dependencies.clone();
2205        if transitive {
2206            let mut pending = selected.clone();
2207            while let Some(id) = pending.pop_first() {
2208                for dependency in &graph.tasks[&id].dependencies {
2209                    if selected.insert(dependency.clone()) {
2210                        pending.insert(dependency.clone());
2211                    }
2212                }
2213            }
2214        }
2215        for id in selected {
2216            let outputs = if fresh {
2217                if !fresh_outputs.contains_key(&id) {
2218                    fresh_outputs.insert(
2219                        id.clone(),
2220                        Arc::new(snapshot.fresh_dependency_outputs(workspace, &graph.tasks[&id])?),
2221                    );
2222                }
2223                fresh_outputs[&id].clone()
2224            } else {
2225                snapshot.dependency_outputs(workspace, &graph.tasks[&id])?
2226            };
2227            let mut files = BTreeMap::new();
2228            for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
2229                let known = outputs.values.lock().unwrap().get(path).cloned();
2230                let value = match known {
2231                    Some(value) => value,
2232                    None => {
2233                        let value = snapshot.file_value(&workspace.root, path)?;
2234                        // Links depend on paths outside the declared output roots.
2235                        if value.get("link").is_none() {
2236                            outputs
2237                                .values
2238                                .lock()
2239                                .unwrap()
2240                                .insert(path.clone(), value.clone());
2241                        }
2242                        value
2243                    }
2244                };
2245                files.insert(path.clone(), value);
2246            }
2247            keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
2248        }
2249    }
2250    Ok(keys)
2251}
2252
2253#[cfg(test)]
2254mod tests {
2255    use super::*;
2256    use qk_taskgraph::Request;
2257
2258    fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
2259        let root = tempfile::tempdir().unwrap();
2260        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
2261        let mut post = json!({"command": "true"});
2262        if let Some(outputs) = outputs {
2263            post["outputs"] = outputs;
2264        }
2265        std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
2266            "gen": {"command":"true", "outputs":["generated"]},
2267            "post": post,
2268            "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
2269        }}).to_string()).unwrap();
2270        std::fs::create_dir(root.path().join("generated")).unwrap();
2271        std::fs::write(root.path().join("generated/value"), "one").unwrap();
2272        let workspace = Workspace::load(root.path()).unwrap();
2273        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2274        (root, workspace, graph)
2275    }
2276
2277    #[test]
2278    fn execution_fingerprints_refresh_sources_and_preserve_artifact_exclusions() {
2279        let (root, mut workspace, _) = fixture(Some(json!(["dist"])));
2280        let definition = workspace
2281            .projects
2282            .get_mut("app")
2283            .unwrap()
2284            .targets
2285            .get_mut("post")
2286            .unwrap();
2287        definition
2288            .extra
2289            .insert("qk:warm".into(), json!({"paths": ["scratch"]}));
2290        std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
2291        std::fs::create_dir(root.path().join("src")).unwrap();
2292        std::fs::write(root.path().join("src/original"), "one").unwrap();
2293        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2294        let task = &graph.tasks["app:post"];
2295        let prepared = qk_executor::prepare(&workspace, task, &BTreeMap::new()).unwrap();
2296        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2297        let inputs = || {
2298            super::inputs(
2299                &snapshot,
2300                &workspace,
2301                task,
2302                &prepared,
2303                &BTreeMap::new(),
2304                &AtomicBool::new(false),
2305            )
2306            .unwrap()
2307        };
2308        let before = inputs();
2309        // Artifacts created after startup must not change the execution key.
2310        for directory in ["generated", "dist", "scratch", "cache", "ignored"] {
2311            std::fs::create_dir_all(root.path().join(directory)).unwrap();
2312            std::fs::write(root.path().join(directory).join("new"), "artifact").unwrap();
2313        }
2314        assert_eq!(before, inputs());
2315        // This covers both post-execution verification and a later task's first
2316        // fingerprint against a snapshot created before this source existed.
2317        std::fs::write(root.path().join("src/added"), "two").unwrap();
2318        let after = inputs();
2319        assert!(after["files"].get("src/added").is_some());
2320        assert_ne!(key(&before).unwrap(), key(&after).unwrap());
2321        std::fs::remove_file(root.path().join("src/added")).unwrap();
2322        assert_eq!(before, inputs());
2323        std::fs::remove_file(root.path().join("src/original")).unwrap();
2324        assert!(inputs()["files"].get("src/original").is_none());
2325    }
2326
2327    #[test]
2328    fn membership_reuses_settled_candidates_and_detects_directory_changes() {
2329        let mut fixtures = Vec::new();
2330        for git in [false, true] {
2331            let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2332            for directory in ["src/empty", "dist", "cache", "ignored"] {
2333                std::fs::create_dir_all(root.path().join(directory)).unwrap();
2334            }
2335            std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
2336            std::fs::write(root.path().join("src/original"), "one").unwrap();
2337            if git {
2338                assert!(
2339                    paths::git(root.path(), &["init", "-q"])
2340                        .unwrap()
2341                        .status
2342                        .success()
2343                );
2344                assert!(
2345                    paths::git(root.path(), &["add", "."])
2346                        .unwrap()
2347                        .status
2348                        .success()
2349                );
2350            }
2351            let snapshot =
2352                Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2353            snapshot.current_files(root.path()).unwrap();
2354            fixtures.push((root, snapshot));
2355        }
2356        // Recent directory metadata deliberately uses the conservative listing
2357        // path. Waiting lets this test exercise the actual reuse path too.
2358        std::thread::sleep(Duration::from_millis(2100));
2359        for (root, snapshot) in fixtures {
2360            let files = || snapshot.current_files(root.path()).unwrap();
2361            let before = files();
2362            assert!(Arc::ptr_eq(&before, &files()));
2363            for directory in ["generated", "dist", "cache"] {
2364                std::fs::write(root.path().join(directory).join("new"), "artifact").unwrap();
2365            }
2366            assert!(Arc::ptr_eq(&before, &files()));
2367            std::fs::write(root.path().join("src/empty/added"), "two").unwrap();
2368            assert!(files().contains("src/empty/added"));
2369            std::fs::create_dir_all(root.path().join("src/new/nested")).unwrap();
2370            std::fs::write(root.path().join("src/new/nested/added"), "three").unwrap();
2371            assert!(files().contains("src/new/nested/added"));
2372            std::fs::rename(
2373                root.path().join("src/original"),
2374                root.path().join("src/renamed"),
2375            )
2376            .unwrap();
2377            let renamed = files();
2378            assert!(!renamed.contains("src/original"));
2379            assert!(renamed.contains("src/renamed"));
2380            std::fs::remove_file(root.path().join("src/renamed")).unwrap();
2381            assert!(!files().contains("src/renamed"));
2382            // Policy files must be checked even when directory entries do not
2383            // change (editing an existing ignore file in place).
2384            std::fs::write(root.path().join(".gitignore"), "ignored/\nsrc/empty/\n").unwrap();
2385            assert!(!files().contains("src/empty/added"));
2386            std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
2387            assert!(files().contains("src/empty/added"));
2388        }
2389    }
2390
2391    #[test]
2392    fn identical_ignore_rewrites_reuse_candidates_and_changed_rules_refresh() {
2393        for name in [".gitignore", ".nxignore"] {
2394            let (root, workspace, graph) = fixture(None);
2395            let policy = root.path().join(name);
2396            std::fs::write(root.path().join("alpha"), "a").unwrap();
2397            std::fs::write(root.path().join("bravo"), "b").unwrap();
2398            std::fs::write(&policy, "alpha\n").unwrap();
2399            let snapshot =
2400                Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2401            let original = snapshot.current_files(root.path()).unwrap();
2402            assert!(!original.contains("alpha"));
2403            assert!(original.contains("bravo"));
2404            for _ in 0..3 {
2405                std::fs::write(&policy, "alpha\n").unwrap();
2406                assert!(Arc::ptr_eq(
2407                    &original,
2408                    &snapshot.current_files(root.path()).unwrap()
2409                ));
2410            }
2411            // Same-length content can change while coarse timestamps appear
2412            // identical. Recent policies must still compare their contents.
2413            std::fs::write(&policy, "bravo\n").unwrap();
2414            {
2415                let mut cached = snapshot.membership.lock().unwrap();
2416                Arc::make_mut(cached.as_mut().unwrap())
2417                    .stamps
2418                    .insert(policy.clone(), membership_stamp(&policy).unwrap());
2419            }
2420            let changed = snapshot.current_files(root.path()).unwrap();
2421            assert!(!Arc::ptr_eq(&original, &changed));
2422            assert!(changed.contains("alpha"));
2423            assert!(!changed.contains("bravo"));
2424            std::fs::remove_file(&policy).unwrap();
2425            assert!(
2426                snapshot
2427                    .current_files(root.path())
2428                    .unwrap()
2429                    .contains("bravo")
2430            );
2431        }
2432    }
2433
2434    #[test]
2435    fn ignore_contents_changed_after_inventory_cannot_bless_a_listing() {
2436        let (root, workspace, graph) = fixture(None);
2437        let policy = root.path().join(".nxignore");
2438        std::fs::write(&policy, "alpha\n").unwrap();
2439        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2440        snapshot.current_files(root.path()).unwrap();
2441        let membership = snapshot.membership.lock().unwrap().clone().unwrap();
2442        std::fs::write(&policy, "bravo\n").unwrap();
2443        assert!(!membership.state(None).unwrap().0);
2444    }
2445
2446    #[test]
2447    fn a_directory_missed_by_the_inventory_cannot_enable_reuse() {
2448        let (root, workspace, graph) = fixture(None);
2449        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2450        snapshot.current_files(root.path()).unwrap();
2451        std::fs::create_dir(root.path().join("new-empty")).unwrap();
2452        let mut cached = snapshot.membership.lock().unwrap();
2453        let membership = Arc::make_mut(cached.as_mut().unwrap());
2454        // Model creation between the directory walk and child-name capture:
2455        // the new directory is in its parent's names but not in the inventory.
2456        membership
2457            .directory_entries
2458            .get_mut(root.path())
2459            .unwrap()
2460            .insert((OsString::from("new-empty"), 1));
2461        assert!(membership.entries(root.path()).unwrap().is_none());
2462        assert!(!membership.state(None).unwrap().0);
2463    }
2464
2465    #[test]
2466    fn a_policy_missed_by_discovery_cannot_enable_reuse() {
2467        let (root, workspace, graph) = fixture(None);
2468        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2469        snapshot.current_files(root.path()).unwrap();
2470        std::fs::write(root.path().join(".nxignore"), "hidden\n").unwrap();
2471        let mut cached = snapshot.membership.lock().unwrap();
2472        let membership = Arc::make_mut(cached.as_mut().unwrap());
2473        // Model a new policy appearing after discovery but before capturing
2474        // its parent's entries; its content would otherwise have no stamp.
2475        membership
2476            .directory_entries
2477            .get_mut(root.path())
2478            .unwrap()
2479            .insert((OsString::from(".nxignore"), 0));
2480        assert!(membership.entries(root.path()).unwrap().is_none());
2481        assert!(!membership.state(None).unwrap().0);
2482    }
2483
2484    #[test]
2485    fn output_publication_reuses_candidates_but_source_names_still_refresh() {
2486        for partial in [false, true] {
2487            let outputs = if partial {
2488                json!(["dist", "!dist/keep"])
2489            } else {
2490                json!(["dist"])
2491            };
2492            let (root, workspace, graph) = fixture(Some(outputs));
2493            std::fs::create_dir_all(root.path().join("src")).unwrap();
2494            std::fs::write(root.path().join("src/original"), "one").unwrap();
2495            if partial {
2496                std::fs::create_dir_all(root.path().join("dist/keep")).unwrap();
2497            }
2498            let snapshot =
2499                Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2500            snapshot.current_files(root.path()).unwrap();
2501            // Policies retain their conservative timestamp window. Directory
2502            // names are freshly verified throughout the window instead.
2503            std::thread::sleep(Duration::from_millis(2100));
2504            let before = snapshot.current_files(root.path()).unwrap();
2505            std::fs::create_dir_all(root.path().join("dist")).unwrap();
2506            std::fs::write(root.path().join("dist/restored"), "artifact").unwrap();
2507            let restored = snapshot.current_files(root.path()).unwrap();
2508            assert!(Arc::ptr_eq(&before, &restored));
2509            std::fs::remove_file(root.path().join("dist/restored")).unwrap();
2510            assert!(Arc::ptr_eq(
2511                &before,
2512                &snapshot.current_files(root.path()).unwrap()
2513            ));
2514            let source = if partial { "dist/keep/new" } else { "src/new" };
2515            std::fs::write(root.path().join(source), "source").unwrap();
2516            let after = snapshot.current_files(root.path()).unwrap();
2517            assert!(after.contains(source));
2518            assert!(!Arc::ptr_eq(&before, &after));
2519            std::fs::remove_file(root.path().join(source)).unwrap();
2520            assert!(
2521                !snapshot
2522                    .current_files(root.path())
2523                    .unwrap()
2524                    .contains(source)
2525            );
2526        }
2527    }
2528
2529    #[test]
2530    fn concurrent_fingerprints_refresh_without_replacing_newer_membership() {
2531        let (root, workspace, graph) = fixture(None);
2532        std::fs::create_dir_all(root.path().join("src")).unwrap();
2533        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2534        let original = snapshot.current_files(root.path()).unwrap();
2535        let barrier = std::sync::Barrier::new(9);
2536        std::thread::scope(|scope| {
2537            let workers: Vec<_> = (0..8)
2538                .map(|_| {
2539                    scope.spawn(|| {
2540                        let mut rounds = Vec::new();
2541                        for _ in 0..6 {
2542                            barrier.wait();
2543                            rounds.push(snapshot.current_files(root.path()));
2544                            barrier.wait();
2545                        }
2546                        rounds
2547                    })
2548                })
2549                .collect();
2550            for round in 0..6 {
2551                std::fs::write(root.path().join(format!("src/new{round}")), "source").unwrap();
2552                barrier.wait();
2553                barrier.wait();
2554            }
2555            // Assertions run after synchronization, so a failed membership
2556            // check cannot strand other workers at a barrier.
2557            for worker in workers {
2558                for (round, files) in worker.join().unwrap().into_iter().enumerate() {
2559                    let files = files.unwrap();
2560                    for added in 0..=round {
2561                        assert!(files.contains(&format!("src/new{added}")));
2562                    }
2563                }
2564            }
2565        });
2566        assert!(!original.contains("src/new0"));
2567        assert!(
2568            snapshot
2569                .current_files(root.path())
2570                .unwrap()
2571                .contains("src/new5")
2572        );
2573    }
2574
2575    #[test]
2576    fn retained_candidates_keep_their_dependency_output_ignore_policy() {
2577        let (root, mut workspace, _) = fixture(None);
2578        workspace
2579            .projects
2580            .get_mut("app")
2581            .unwrap()
2582            .targets
2583            .get_mut("build")
2584            .unwrap()
2585            .inputs = Some(vec![json!("{workspaceRoot}/**/*.txt")]);
2586        std::fs::create_dir(root.path().join("src")).unwrap();
2587        for name in ["alpha", "beta", "gamma"] {
2588            for directory in ["src", "generated"] {
2589                std::fs::write(root.path().join(format!("{directory}/{name}.txt")), name).unwrap();
2590            }
2591        }
2592        let policy = |keep: &str| {
2593            let ignored: String = ["alpha", "beta", "gamma"]
2594                .into_iter()
2595                .filter(|name| *name != keep)
2596                .flat_map(|name| {
2597                    [
2598                        format!("src/{name}.txt\n"),
2599                        format!("generated/{name}.txt\n"),
2600                    ]
2601                })
2602                .collect();
2603            std::fs::write(root.path().join(".nxignore"), ignored).unwrap();
2604        };
2605        policy("alpha");
2606        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2607        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
2608        let first = snapshot
2609            .current_files_for(root.path(), None, "test")
2610            .unwrap();
2611        policy("beta");
2612        let second = snapshot
2613            .current_files_for(root.path(), None, "test")
2614            .unwrap();
2615        policy("gamma");
2616        let third = snapshot
2617            .current_files_for(root.path(), None, "test")
2618            .unwrap();
2619        // Deterministically pause two fingerprints after candidate acquisition,
2620        // then let another fingerprint refresh policy twice before they resolve.
2621        let cancelled = AtomicBool::new(false);
2622        let expected = |name: &str| {
2623            BTreeSet::from([
2624                ".nxignore".into(),
2625                "nx.json".into(),
2626                "project.json".into(),
2627                format!("src/{name}.txt"),
2628                format!("generated/{name}.txt"),
2629            ])
2630        };
2631        for (candidates, name) in [(first, "alpha"), (second, "beta"), (third, "gamma")] {
2632            let resolved = resolve_candidates(
2633                &snapshot,
2634                &workspace,
2635                &graph.tasks["app:build"],
2636                None,
2637                &cancelled,
2638                &candidates.files,
2639                &candidates.source_ignore,
2640            )
2641            .unwrap();
2642            assert_eq!(resolved.files, expected(name));
2643        }
2644        // Inspection still uses its original candidate list and original policy.
2645        let inspected = resolve(
2646            &snapshot,
2647            &workspace,
2648            &graph.tasks["app:build"],
2649            None,
2650            &cancelled,
2651        )
2652        .unwrap();
2653        assert_eq!(inspected.files, expected("alpha"));
2654    }
2655
2656    #[test]
2657    fn scoped_membership_defers_unrelated_changes_until_a_matching_task_asks() {
2658        let (root, mut workspace, _) = fixture(Some(json!(["dist"])));
2659        let targets = &mut workspace.projects.get_mut("app").unwrap().targets;
2660        targets.get_mut("post").unwrap().inputs =
2661            Some(vec![json!("{workspaceRoot}/left/**/*.txt")]);
2662        targets.get_mut("gen").unwrap().inputs =
2663            Some(vec![json!("{workspaceRoot}/right/**/*.txt")]);
2664        targets.get_mut("build").unwrap().inputs = Some(vec![json!("{workspaceRoot}/**/*.txt")]);
2665        for directory in ["left/empty", "right/empty"] {
2666            std::fs::create_dir_all(root.path().join(directory)).unwrap();
2667        }
2668        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2669        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2670        snapshot.current_files(&workspace.root).unwrap();
2671        std::thread::sleep(Duration::from_millis(2100));
2672        let left = snapshot
2673            .membership_prefixes(&workspace, &graph.tasks["app:post"])
2674            .unwrap();
2675        let right = snapshot
2676            .membership_prefixes(&workspace, &graph.tasks["app:gen"])
2677            .unwrap();
2678        let all = snapshot
2679            .membership_prefixes(&workspace, &graph.tasks["app:build"])
2680            .unwrap();
2681        let before = snapshot
2682            .current_files_for(&workspace.root, Some(&left), "test")
2683            .unwrap();
2684        std::fs::write(root.path().join("right/empty/new.txt"), "right").unwrap();
2685        let unrelated = snapshot
2686            .current_files_for(&workspace.root, Some(&left), "test")
2687            .unwrap();
2688        assert!(Arc::ptr_eq(&before.files, &unrelated.files));
2689        assert!(!unrelated.files.contains("right/empty/new.txt"));
2690        assert!(
2691            snapshot
2692                .current_files_for(&workspace.root, Some(&right), "test")
2693                .unwrap()
2694                .files
2695                .contains("right/empty/new.txt")
2696        );
2697        std::fs::create_dir_all(root.path().join("left/empty/nested")).unwrap();
2698        std::fs::write(root.path().join("left/empty/nested/new.txt"), "left").unwrap();
2699        assert!(
2700            snapshot
2701                .current_files_for(&workspace.root, Some(&left), "test")
2702                .unwrap()
2703                .files
2704                .contains("left/empty/nested/new.txt")
2705        );
2706        std::fs::write(root.path().join("right/empty/another.txt"), "right").unwrap();
2707        assert!(
2708            snapshot
2709                .current_files_for(&workspace.root, Some(&all), "test")
2710                .unwrap()
2711                .files
2712                .contains("right/empty/another.txt")
2713        );
2714    }
2715
2716    #[test]
2717    fn membership_watches_git_ignored_tracked_parents_and_index_policy() {
2718        let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2719        std::fs::create_dir_all(root.path().join("tracked/nested")).unwrap();
2720        std::fs::write(root.path().join("tracked/.gitignore"), "nested/\n").unwrap();
2721        std::fs::write(root.path().join("tracked/nested/source"), "one").unwrap();
2722        assert!(
2723            paths::git(root.path(), &["init", "-q"])
2724                .unwrap()
2725                .status
2726                .success()
2727        );
2728        assert!(
2729            paths::git(root.path(), &["add", "-f", "tracked/nested/source"])
2730                .unwrap()
2731                .status
2732                .success()
2733        );
2734        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2735        let files = || snapshot.current_files(root.path()).unwrap();
2736        assert!(files().contains("tracked/nested/source"));
2737        std::fs::remove_file(root.path().join("tracked/nested/source")).unwrap();
2738        assert!(!files().contains("tracked/nested/source"));
2739        std::fs::write(root.path().join("tracked/nested/another"), "two").unwrap();
2740        assert!(!files().contains("tracked/nested/another"));
2741        assert!(
2742            paths::git(root.path(), &["add", "-f", "tracked/nested/another"])
2743                .unwrap()
2744                .status
2745                .success()
2746        );
2747        assert!(files().contains("tracked/nested/another"));
2748        std::fs::write(root.path().join("untracked"), "three").unwrap();
2749        assert!(files().contains("untracked"));
2750        std::fs::write(root.path().join(".git/info/exclude"), "untracked\n").unwrap();
2751        assert!(!files().contains("untracked"));
2752    }
2753
2754    #[test]
2755    fn a_new_git_config_include_invalidates_settled_listing_policy() {
2756        let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2757        let policy = tempfile::tempdir().unwrap();
2758        let included = policy.path().join("included.config");
2759        let excludes = policy.path().join("excludes");
2760        std::fs::write(&excludes, "hidden\n").unwrap();
2761        std::fs::write(root.path().join("hidden"), "source").unwrap();
2762        assert!(
2763            paths::git(root.path(), &["init", "-q"])
2764                .unwrap()
2765                .status
2766                .success()
2767        );
2768        assert!(
2769            paths::git(
2770                root.path(),
2771                &["config", "include.path", included.to_str().unwrap()]
2772            )
2773            .unwrap()
2774            .status
2775            .success()
2776        );
2777        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2778        snapshot.current_files(&workspace.root).unwrap();
2779        std::thread::sleep(Duration::from_millis(2100));
2780        let before = snapshot.current_files(&workspace.root).unwrap();
2781        assert!(before.contains("hidden"));
2782        assert!(Arc::ptr_eq(
2783            &before,
2784            &snapshot.current_files(&workspace.root).unwrap()
2785        ));
2786        assert!(
2787            paths::git(
2788                root.path(),
2789                &[
2790                    "config",
2791                    "--file",
2792                    included.to_str().unwrap(),
2793                    "core.excludesfile",
2794                    excludes.to_str().unwrap(),
2795                ]
2796            )
2797            .unwrap()
2798            .status
2799            .success()
2800        );
2801        assert!(
2802            !snapshot
2803                .current_files(&workspace.root)
2804                .unwrap()
2805                .contains("hidden")
2806        );
2807    }
2808
2809    #[cfg(unix)]
2810    #[test]
2811    fn git_policy_paths_preserve_quoted_origins_and_multiline_values() {
2812        let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2813        let policy = tempfile::tempdir().unwrap();
2814        // Git's text output C-quotes these origins and octal-escapes UTF-8.
2815        let included = policy.path().join("included\\é\t\n.config");
2816        let excludes = policy.path().join("excludes\\é\t\n");
2817        let replacement = policy.path().join("replacement\n");
2818        std::fs::write(&excludes, "hidden\n").unwrap();
2819        std::fs::write(&replacement, "visible\n").unwrap();
2820        for file in ["hidden", "visible"] {
2821            std::fs::write(root.path().join(file), "source").unwrap();
2822        }
2823        let git = |args: &[&str]| {
2824            let output = paths::git(root.path(), args).unwrap();
2825            assert!(output.status.success(), "{output:?}");
2826            output
2827        };
2828        git(&["init", "-q"]);
2829        git(&["config", "include.path", included.to_str().unwrap()]);
2830        git(&[
2831            "config",
2832            "--file",
2833            included.to_str().unwrap(),
2834            "core.excludesfile",
2835            excludes.to_str().unwrap(),
2836        ]);
2837        let text = git(&["config", "--show-origin", "--list"]);
2838        let text = String::from_utf8_lossy(&text.stdout);
2839        let quoted_origin = text
2840            .lines()
2841            .find_map(|line| {
2842                line.strip_prefix("file:\"")
2843                    .and_then(|_| line.split_once('\t'))
2844            })
2845            .unwrap()
2846            .0
2847            .strip_prefix("file:")
2848            .unwrap();
2849        let policies = git_membership_policies(root.path());
2850        assert!(!policies.contains(&root.path().join(quoted_origin)));
2851        assert!(policies.contains(&included));
2852        assert!(policies.contains(&excludes));
2853        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2854        let initial = snapshot.current_files(&workspace.root).unwrap();
2855        assert!(!initial.contains("hidden"));
2856        assert!(initial.contains("visible"));
2857        std::thread::sleep(Duration::from_millis(2100));
2858        let settled = snapshot.current_files(&workspace.root).unwrap();
2859        assert!(Arc::ptr_eq(
2860            &settled,
2861            &snapshot.current_files(&workspace.root).unwrap()
2862        ));
2863        git(&[
2864            "config",
2865            "--file",
2866            included.to_str().unwrap(),
2867            "core.excludesfile",
2868            replacement.to_str().unwrap(),
2869        ]);
2870        let changed = snapshot.current_files(&workspace.root).unwrap();
2871        assert!(changed.contains("hidden"));
2872        assert!(!changed.contains("visible"));
2873        assert!(git_membership_policies(root.path()).contains(&replacement));
2874    }
2875
2876    #[cfg(unix)]
2877    #[test]
2878    fn symlinked_ignore_policy_never_reuses_stale_membership() {
2879        let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2880        let policy = tempfile::NamedTempFile::new().unwrap();
2881        std::fs::write(policy.path(), "hidden\n").unwrap();
2882        std::os::unix::fs::symlink(policy.path(), root.path().join(".gitignore")).unwrap();
2883        std::fs::write(root.path().join("hidden"), "source").unwrap();
2884        let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2885        assert!(
2886            !snapshot
2887                .current_files(root.path())
2888                .unwrap()
2889                .contains("hidden")
2890        );
2891        assert!(
2892            !snapshot
2893                .membership
2894                .lock()
2895                .unwrap()
2896                .as_ref()
2897                .unwrap()
2898                .settled
2899        );
2900        std::thread::sleep(Duration::from_millis(2100));
2901        let before = snapshot.current_files(root.path()).unwrap();
2902        assert!(Arc::ptr_eq(
2903            &before,
2904            &snapshot.current_files(root.path()).unwrap()
2905        ));
2906        std::fs::write(policy.path(), "").unwrap();
2907        assert!(
2908            snapshot
2909                .current_files(root.path())
2910                .unwrap()
2911                .contains("hidden")
2912        );
2913    }
2914
2915    #[cfg(unix)]
2916    #[test]
2917    fn recent_change_time_prevents_membership_reuse_despite_old_mtime() {
2918        use std::os::unix::fs::MetadataExt;
2919        let root = tempfile::tempdir().unwrap();
2920        let old = SystemTime::UNIX_EPOCH + Duration::from_secs(1);
2921        File::open(root.path())
2922            .unwrap()
2923            .set_times(std::fs::FileTimes::new().set_modified(old))
2924            .unwrap();
2925        let metadata = std::fs::metadata(root.path()).unwrap();
2926        assert!(metadata.ctime() > 1);
2927        let stamp = Stamp::new(&metadata);
2928        assert!(stamp.settled());
2929        assert!(!stamp.membership_settled());
2930    }
2931
2932    #[test]
2933    fn inspection_keeps_deleted_candidates() {
2934        let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2935        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache"))
2936            .unwrap()
2937            .with_candidates(&["src/deleted".into()]);
2938        let resolved = resolve(
2939            &snapshot,
2940            &workspace,
2941            &graph.tasks["app:post"],
2942            None,
2943            &AtomicBool::new(false),
2944        )
2945        .unwrap();
2946        assert!(resolved.files.contains("src/deleted"));
2947    }
2948
2949    #[test]
2950    fn undeclared_writes_invalidate_dependency_values() {
2951        for outputs in [None, Some(json!(["generated"]))] {
2952            let (root, workspace, graph) = fixture(outputs);
2953            let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
2954            let keys = || {
2955                dependency_keys(
2956                    &snapshot,
2957                    &workspace,
2958                    &graph,
2959                    &graph.tasks["app:build"],
2960                    &BTreeMap::new(),
2961                    &AtomicBool::new(false),
2962                )
2963                .unwrap()
2964            };
2965            let before = keys();
2966            std::fs::write(root.path().join("generated/value"), "different").unwrap();
2967            snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
2968            assert_ne!(before, keys());
2969        }
2970    }
2971
2972    #[cfg(unix)]
2973    #[test]
2974    fn dependency_symlinks_recheck_targets_outside_output_roots() {
2975        let (root, mut workspace, _) = fixture(Some(json!(["generated"])));
2976        workspace
2977            .projects
2978            .get_mut("app")
2979            .unwrap()
2980            .targets
2981            .get_mut("gen")
2982            .unwrap()
2983            .outputs = Some(vec!["links".into()]);
2984        std::fs::create_dir(root.path().join("links")).unwrap();
2985        std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
2986        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2987        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
2988        let keys = || {
2989            dependency_keys(
2990                &snapshot,
2991                &workspace,
2992                &graph,
2993                &graph.tasks["app:build"],
2994                &BTreeMap::new(),
2995                &AtomicBool::new(false),
2996            )
2997            .unwrap()
2998        };
2999        let before = keys();
3000        std::fs::write(root.path().join("generated/value"), "different").unwrap();
3001        snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
3002        assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
3003    }
3004}