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