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