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