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, SystemTime};
9
10use anyhow::{Context, Result, bail};
11use foldhash::{HashMap, HashMapExt, HashSet};
12use qk_config::Workspace;
13use qk_executor::{Capture, Display, Outcome, PreparedTask, execute_captured, read_capture};
14use qk_graph::ProjectGraph;
15use qk_lockfile::Lockfile;
16use qk_taskgraph::{Task, TaskGraph};
17use serde_json::{Value, json};
18
19use crate::glob::is_literal;
20use crate::inputs::{Resolved, workspace_without_resolution};
21use crate::paths::{self, Outputs, Pattern};
22
23pub fn digest_file(path: &Path) -> Result<String> {
24 let mut hasher = blake3::Hasher::new();
25 let mut reader = File::open(path)?;
26 let mut buffer = [0; 64 * 1024];
27 loop {
28 let count = reader.read(&mut buffer)?;
29 if count == 0 {
30 break;
31 }
32 hasher.update(&buffer[..count]);
33 }
34 Ok(hasher.finalize().to_hex().to_string())
35}
36
37#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
40struct Stamp(Vec<i64>);
41
42impl Stamp {
43 fn new(metadata: &std::fs::Metadata) -> Self {
44 let modified = metadata
45 .modified()
46 .ok()
47 .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
48 .map_or(-1, |duration| duration.as_nanos() as i64);
49 #[allow(unused_mut)]
50 let mut values = vec![metadata.len() as i64, modified];
51 #[cfg(unix)]
52 {
53 use std::os::unix::fs::MetadataExt;
54 values.extend([
55 metadata.ctime(),
56 metadata.ctime_nsec(),
57 metadata.ino() as i64,
58 i64::from(metadata.mode()),
59 ]);
60 }
61 Self(values)
62 }
63
64 fn settled(&self) -> bool {
67 let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
68 self.0[1] >= 0
69 && SystemTime::now()
70 .duration_since(modified)
71 .is_ok_and(|age| age > Duration::from_secs(2))
72 }
73}
74
75fn digests_path(root: &Path) -> PathBuf {
79 paths::worktree_state(root).join("digests.json")
80}
81
82#[derive(PartialEq, Eq, Hash)]
83struct RuntimeKey {
84 command: String,
85 environment: BTreeMap<OsString, OsString>,
86}
87
88pub struct Snapshot {
92 pub(crate) files: BTreeSet<String>,
93 extra_candidates: BTreeSet<String>,
94 projects: ProjectGraph,
95 generated_tasks: Vec<(Task, Outputs)>,
96 task_dependencies: BTreeMap<String, BTreeSet<String>>,
97 source_ignore: SourceIgnore,
98 canonical_root: PathBuf,
99 workspace_prefix: Option<PathBuf>,
100 patterns: Mutex<HashMap<(String, bool), Arc<Pattern>>>,
101 digests: Mutex<HashMap<String, (Stamp, String)>>,
102 digests_changed: std::sync::atomic::AtomicBool,
104 directories: Mutex<HashSet<PathBuf>>,
106 runtime: Mutex<HashMap<RuntimeKey, Arc<Mutex<Option<Value>>>>>,
108 lockfile: Mutex<Option<Arc<Installed>>>,
110 dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
113 outputs_written: std::sync::atomic::AtomicU64,
115}
116
117struct DependencyOutputs {
120 anchors: Vec<String>,
121 files: Vec<String>,
122 values: Mutex<HashMap<String, Value>>,
123}
124
125struct Installed {
128 content: String,
129 lockfile: Option<Lockfile>,
131 digests: Mutex<HashMap<String, String>>,
132}
133
134impl Installed {
135 fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
136 if let Some(digest) = self.digests.lock().unwrap().get(key) {
137 return digest.clone();
138 }
139 let mut hasher = blake3::Hasher::new();
140 for fingerprint in fingerprints() {
141 hasher.update(fingerprint.as_bytes());
142 hasher.update(b"\n");
143 }
144 let digest = hasher.finalize().to_hex().to_string();
145 self.digests
146 .lock()
147 .unwrap()
148 .insert(key.to_owned(), digest.clone());
149 digest
150 }
151}
152
153impl Snapshot {
154 pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
155 let canonical_root = workspace.root.canonicalize()?;
158 let (files, lockfile, (digests, workspace_prefix)) = std::thread::scope(|scope| {
159 let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
160 let digests = scope.spawn(|| {
161 let digests = load_digests(&workspace.root);
162 let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
163 .and_then(|root| root.canonicalize().ok())
164 .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
165 (digests, workspace_prefix)
166 });
167 let files = source_files(&workspace.root);
168 (
169 files,
170 lockfile.join().expect("lockfile thread panicked"),
171 digests.join().expect("digest thread panicked"),
172 )
173 });
174 let mut files = files?;
175 if let Ok(cache_relative) = cache_path.strip_prefix(&workspace.root) {
176 files.retain(|path| !Path::new(path).starts_with(cache_relative));
177 }
178 let mut generated = BTreeSet::new();
182 for outputs in graph
183 .tasks
184 .values()
185 .filter_map(|task| Outputs::new(workspace, task).ok())
186 .filter(Outputs::is_explicit)
187 {
188 for anchor in outputs.anchors() {
189 generated.extend(
190 under(&files, anchor)
191 .filter(|path| outputs.matches(path))
192 .cloned(),
193 );
194 }
195 }
196 for task in graph.tasks.values() {
198 let Ok(Some(warm)) = crate::warm::config(workspace, task) else {
199 continue;
200 };
201 if let Ok(paths) = Outputs::from_paths(&warm.kept_paths()) {
202 for anchor in paths.anchors() {
203 generated.extend(
204 under(&files, anchor)
205 .filter(|path| paths.matches(path))
206 .cloned(),
207 );
208 }
209 }
210 }
211 files.retain(|path| !generated.contains(path));
212 Ok(Self {
213 files,
214 extra_candidates: BTreeSet::new(),
215 projects: ProjectGraph::build(workspace)?,
216 source_ignore: SourceIgnore::new(&workspace.root)?,
217 generated_tasks: graph
218 .tasks
219 .values()
220 .filter_map(|task| {
221 let outputs = Outputs::new(workspace, task).ok()?;
222 outputs.is_explicit().then(|| (task.clone(), outputs))
223 })
224 .collect(),
225 task_dependencies: graph
226 .tasks
227 .iter()
228 .map(|(id, task)| (id.clone(), task.dependencies.clone()))
229 .collect(),
230 canonical_root,
231 workspace_prefix,
232 patterns: Mutex::default(),
233 digests: Mutex::new(digests),
234 digests_changed: Default::default(),
235 directories: Mutex::default(),
236 runtime: Mutex::default(),
237 lockfile: Mutex::new(lockfile),
238 dependency_outputs: Mutex::default(),
239 outputs_written: Default::default(),
240 })
241 }
242
243 pub fn save_digests(&self, root: &Path) {
245 if !self
246 .digests_changed
247 .load(std::sync::atomic::Ordering::Relaxed)
248 {
249 return;
250 }
251 let digests = self.digests.lock().unwrap();
252 let entries: BTreeMap<&String, (&Stamp, &String)> = digests
253 .iter()
254 .filter(|(path, _)| self.files.contains(*path))
255 .map(|(path, (stamp, digest))| (path, (stamp, digest)))
256 .collect();
257 let path = digests_path(root);
258 let saved = (|| -> Result<()> {
259 let directory = path.parent().context("digests have a directory")?;
260 std::fs::create_dir_all(directory)?;
261 let mut file = tempfile::NamedTempFile::new_in(directory)?;
262 {
263 let mut writer = std::io::BufWriter::new(file.as_file_mut());
264 serde_json::to_writer(&mut writer, &json!({"version": 1, "entries": entries}))?;
265 writer.flush()?;
266 }
267 file.persist(&path)?;
268 Ok(())
269 })();
270 if let Err(error) = saved {
271 qk_executor::status!("qk: could not save file digests: {error:#}");
272 }
273 }
274
275 fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
277 paths::validate_path(path)?;
278 let mut current = root.to_owned();
279 for part in Path::new(path)
280 .parent()
281 .into_iter()
282 .flat_map(Path::components)
283 {
284 current.push(part);
285 if self.directories.lock().unwrap().contains(¤t) {
286 continue;
287 }
288 match std::fs::symlink_metadata(¤t) {
289 Ok(metadata) if metadata.file_type().is_symlink() => {
290 bail!("input parent is a symlink: {}", current.display())
291 }
292 Ok(metadata) if !metadata.is_dir() => {
293 bail!("input parent is not a directory: {}", current.display())
294 }
295 Ok(_) => {
296 self.directories.lock().unwrap().insert(current.clone());
297 }
298 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
299 Err(error) => return Err(error.into()),
300 }
301 }
302 Ok(())
303 }
304
305 fn installed(&self, root: &Path) -> Result<Option<Arc<Installed>>> {
306 let absolute = root.join("pnpm-lock.yaml");
307 let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
308 return Ok(None);
309 };
310 if !metadata.is_file() {
311 return Ok(None);
312 }
313 let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
314 let mut current = self.lockfile.lock().unwrap();
315 if let Some(installed) = &*current
316 && installed.content == content
317 {
318 return Ok(Some(installed.clone()));
319 }
320 let Some(installed) = parse_lockfile(&absolute) else {
321 return Ok(None);
322 };
323 *current = Some(installed.clone());
324 Ok(Some(installed))
325 }
326
327 pub fn with_candidates(mut self, paths: &[String]) -> Self {
330 self.files.extend(
331 paths
332 .iter()
333 .filter(|path| !self.source_ignore.matches(path))
334 .cloned(),
335 );
336 self.extra_candidates.extend(paths.iter().cloned());
337 self
338 }
339
340 fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
341 let key = (pattern.to_owned(), negated);
342 if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
343 return Ok(pattern.clone());
344 }
345 let compiled = Arc::new(Pattern::new(pattern, negated)?);
346 self.patterns.lock().unwrap().insert(key, compiled.clone());
347 Ok(compiled)
348 }
349
350 fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
351 let stamp = Stamp::new(metadata);
352 if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
353 && *known == stamp
354 {
355 return Ok(digest.clone());
356 }
357 let digest = digest_file(absolute)?;
358 let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
360 if after == stamp && stamp.settled() {
361 self.digests
362 .lock()
363 .unwrap()
364 .insert(path.to_owned(), (stamp, digest.clone()));
365 self.digests_changed
366 .store(true, std::sync::atomic::Ordering::Relaxed);
367 }
368 Ok(digest)
369 }
370
371 fn dependency_outputs(
374 &self,
375 workspace: &Workspace,
376 task: &Task,
377 ) -> Result<Arc<DependencyOutputs>> {
378 if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
379 return Ok(outputs.clone());
380 }
381 let written = self
382 .outputs_written
383 .load(std::sync::atomic::Ordering::SeqCst);
384 let outputs = Arc::new(self.fresh_dependency_outputs(workspace, task)?);
385 let mut listed = self.dependency_outputs.lock().unwrap();
386 if self
387 .outputs_written
388 .load(std::sync::atomic::Ordering::SeqCst)
389 != written
390 {
391 return Ok(outputs);
392 }
393 Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
394 }
395
396 fn fresh_dependency_outputs(
398 &self,
399 workspace: &Workspace,
400 task: &Task,
401 ) -> Result<DependencyOutputs> {
402 let declared = Outputs::new(workspace, task)?;
403 let mut files = Vec::new();
404 for entry in declared.entries(&workspace.root)? {
405 let (path, metadata) = entry?;
406 if !metadata.is_dir() {
407 files.push(path);
408 }
409 }
410 Ok(DependencyOutputs {
411 anchors: declared.anchors().map(str::to_owned).collect(),
412 files,
413 values: Mutex::default(),
414 })
415 }
416
417 pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
420 let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
421 .ok()
422 .filter(Outputs::is_explicit)
423 .map(|outputs| outputs.anchors().map(str::to_owned).collect());
424 let overlap = |a: &str, b: &str| {
425 a == b
426 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
427 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
428 };
429 let mut listed = self.dependency_outputs.lock().unwrap();
430 self.outputs_written
431 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
432 listed.retain(|_, outputs| {
433 anchors.as_ref().is_some_and(|written| {
434 !written
435 .iter()
436 .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
437 })
438 });
439 }
440
441 fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
442 self.file_value_with_links(root, path, &mut BTreeSet::new())
443 }
444
445 fn file_value_with_links(
446 &self,
447 root: &Path,
448 path: &str,
449 visiting: &mut BTreeSet<PathBuf>,
450 ) -> Result<Value> {
451 self.safe_parents(root, path)?;
452 let absolute = root.join(path);
453 let metadata = std::fs::symlink_metadata(&absolute)?;
454 if metadata.file_type().is_symlink() {
455 let target = std::fs::read_link(&absolute)?;
456 let Ok(resolved) = absolute.canonicalize() else {
458 return Ok(json!({"link":target, "dangling":true}));
459 };
460 let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
461 bail!("input symlink resolves outside the workspace: {path}");
462 };
463 if resolved.is_file() {
464 let metadata = std::fs::metadata(&resolved)?;
465 let key = paths::relative(Path::new(""), relative)?;
466 return Ok(
467 json!({"link":target, "content":self.digest(&key, &resolved, &metadata)?,
468 "mode":crate::store::mode(&metadata) & 0o111 != 0}),
469 );
470 }
471 let relative = paths::relative(Path::new(""), relative)?;
472 if relative.is_empty() {
473 bail!("input symlink resolves to the workspace root: {path}");
474 }
475 if !visiting.insert(resolved.clone()) {
476 bail!("directory input symlink cycle: {path}");
477 }
478 let mut contents = BTreeMap::new();
479 for entry in walkdir::WalkDir::new(&resolved).follow_links(false) {
482 let entry = entry?;
483 if entry.file_type().is_dir() {
484 continue;
485 }
486 let file = paths::relative(&self.canonical_root, entry.path())?;
487 let value = self.file_value_with_links(&self.canonical_root, &file, visiting)?;
488 contents.insert(
489 file.strip_prefix(&relative).unwrap_or(&file).to_owned(),
490 value,
491 );
492 }
493 visiting.remove(&resolved);
494 return Ok(json!({"link":target, "directory":contents}));
495 }
496 if !metadata.is_file() {
497 bail!("input is not a regular file: {path}");
498 }
499 let mode = crate::store::mode(&metadata) & 0o111 != 0;
502 Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
503 }
504}
505
506fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
509 let text = std::fs::read_to_string(path).ok()?;
510 let lockfile = match Lockfile::parse(&text) {
511 Ok(lockfile) => Some(lockfile),
512 Err(error) => {
513 qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
514 None
515 }
516 };
517 Some(Arc::new(Installed {
518 content: blake3::hash(text.as_bytes()).to_hex().to_string(),
519 lockfile,
520 digests: Mutex::default(),
521 }))
522}
523
524fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
525 #[derive(serde::Deserialize)]
526 struct Saved {
527 version: u32,
528 entries: HashMap<String, (Stamp, String)>,
529 }
530 std::fs::read(digests_path(root))
531 .ok()
532 .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
533 .filter(|saved| saved.version == 1)
534 .map(|saved| saved.entries)
535 .unwrap_or_default()
536}
537
538fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
540 files
541 .range::<str, _>((
542 std::ops::Bound::Included(prefix),
543 std::ops::Bound::Unbounded,
544 ))
545 .take_while(move |path| path.starts_with(prefix))
546 .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
547}
548
549pub struct SourceIgnore(ignore::gitignore::Gitignore);
551
552impl SourceIgnore {
553 pub fn new(root: &Path) -> Result<Self> {
555 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
556 for name in [".gitignore", ".nxignore"] {
557 let path = root.join(name);
558 if path.is_file()
559 && let Some(error) = builder.add(path)
560 {
561 return Err(error.into());
562 }
563 }
564 Ok(Self(builder.build()?))
565 }
566
567 fn matches_directory(&self, path: &str) -> bool {
568 self.matches(path) || self.0.matched(path, true).is_ignore()
569 }
570
571 pub fn matches(&self, path: &str) -> bool {
573 Path::new(path)
575 .ancestors()
576 .skip(1)
577 .take_while(|parent| !parent.as_os_str().is_empty())
578 .any(|parent| self.0.matched(parent, true).is_ignore())
579 || self.0.matched(path, false).is_ignore()
580 }
581}
582
583pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
585 let lines =
586 |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
587 let output = output.ok().filter(|output| output.status.success())?;
588 Some(
589 output
590 .stdout
591 .split(|byte| *byte == 0)
592 .filter(|path| !path.is_empty())
593 .map(|path| {
594 String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
595 })
596 .collect(),
597 )
598 };
599 let (listed, deleted) = if root.join(".nxignore").is_file() {
604 (None, None)
607 } else {
608 std::thread::scope(|scope| {
609 let deleted =
610 scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
611 let listed = paths::git(
612 root,
613 &[
614 "ls-files",
615 "-v",
616 "--cached",
617 "--others",
618 "--exclude-standard",
619 "-z",
620 "--",
621 ".",
622 ],
623 );
624 (
625 lines(listed),
626 lines(deleted.join().expect("git listing thread panicked")),
627 )
628 })
629 };
630 let candidates = match (listed, deleted) {
631 (Some(listed), Some(deleted)) => {
632 let deleted = deleted?;
633 let mut files = BTreeSet::new();
634 for entry in listed? {
635 let Some((tag, path)) = entry.split_once(' ') else {
636 bail!("unexpected git ls-files entry {entry:?}");
637 };
638 let absent =
639 matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
640 if !absent && !deleted.contains(path) {
641 files.insert(path.to_owned());
642 }
643 }
644 files
645 }
646 _ => {
647 let mut files = BTreeSet::new();
648 for entry in ignore::WalkBuilder::new(root)
649 .hidden(false)
650 .parents(false)
651 .git_global(false)
652 .require_git(false)
653 .ignore(false)
654 .add_custom_ignore_filename(".nxignore")
655 .follow_links(false)
656 .filter_entry(|entry| {
657 !matches!(
658 entry.file_name().to_str(),
659 Some(".git" | ".qk" | "node_modules")
660 )
661 })
662 .build()
663 {
664 let entry = entry?;
665 if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
666 files.insert(paths::relative(root, entry.path())?);
667 }
668 }
669 files
670 }
671 };
672 let ignore = SourceIgnore::new(root)?;
673 Ok(candidates
674 .into_iter()
675 .filter(|path| {
676 !ignore.matches(path)
677 && !path
678 .split('/')
679 .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
680 })
681 .collect())
682}
683
684fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
685 path.split('.')
686 .try_fold(value, |value, part| value.get(part))
687}
688
689fn set_json_path(target: &mut Value, path: &str, value: Value) {
690 let mut parts = path.split('.').peekable();
691 let mut current = target;
692 while let Some(part) = parts.next() {
693 let Value::Object(object) = current else {
694 return;
695 };
696 if parts.peek().is_none() {
697 object.insert(part.to_owned(), value);
698 return;
699 }
700 current = object.entry(part).or_insert_with(|| json!({}));
701 }
702}
703
704fn remove_json_path(target: &mut Value, path: &str) {
705 let (parent, last) = match path.rsplit_once('.') {
706 Some((parent, last)) => (Some(parent), last),
707 None => (None, path),
708 };
709 let parent = match parent {
710 Some(parent) => parent
711 .split('.')
712 .try_fold(&mut *target, |value, part| value.get_mut(part)),
713 None => Some(target),
714 };
715 if let Some(Value::Object(object)) = parent {
716 object.remove(last);
717 }
718}
719
720struct FilePattern {
721 matcher: Arc<Pattern>,
722 prefix: String,
723 excluded: bool,
724}
725
726#[derive(Default)]
727struct FileSelection {
728 patterns: Vec<FilePattern>,
729 included: BTreeSet<String>,
730 excluded: BTreeSet<String>,
731}
732
733#[derive(PartialEq, Eq, PartialOrd, Ord)]
734enum FileScope {
735 Workspace,
736 Project(String),
737}
738
739struct Resolver<'a> {
740 workspace: &'a Workspace,
741 snapshot: &'a Snapshot,
742 selected: BTreeSet<String>,
743 scopes: BTreeMap<FileScope, FileSelection>,
744 values: BTreeMap<String, Value>,
745 named_stack: Vec<(String, String)>,
746 expanded: BTreeSet<(String, String)>,
748 external: BTreeSet<String>,
749 prepared: Option<&'a PreparedTask>,
752 cancelled: &'a AtomicBool,
753}
754
755impl Resolver<'_> {
756 fn input(&mut self, project: &str, input: &Value) -> Result<()> {
757 match input {
758 Value::String(name) if name.starts_with('^') => {
761 self.dependencies_named(project, &name[1..])?;
762 }
763 Value::Object(object)
765 if object.contains_key("input") && !object.contains_key("fileset") =>
766 {
767 let name = object["input"]
768 .as_str()
769 .context("input must name a named input")?;
770 let projects = object.get("projects");
771 let dependencies = object.get("dependencies") == Some(&json!(true))
772 || projects == Some(&json!("dependencies"));
773 let listed = projects.filter(|projects| {
774 **projects != json!("self") && **projects != json!("dependencies")
775 });
776 if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
777 bail!("named inputs can only refer to named inputs of their own project");
778 }
779 if dependencies {
780 self.dependencies_named(project, name)?;
781 } else if let Some(listed) = listed {
782 let patterns: Vec<String> = match listed {
783 Value::String(pattern) => vec![pattern.clone()],
784 other => serde_json::from_value(other.clone())
785 .context("input projects must be a string or an array")?,
786 };
787 for selected in
788 qk_graph::select_projects(&self.workspace.projects, &patterns, &[])?
789 {
790 if self.expanded.insert((selected.clone(), name.to_owned())) {
791 self.named(&selected, name)?;
792 }
793 }
794 } else {
795 self.named(project, name)?;
796 }
797 }
798 Value::Object(object)
799 if object.contains_key("fileset")
800 && object.get("dependencies") == Some(&json!(true)) =>
801 {
802 if !self.named_stack.is_empty() {
803 bail!("named inputs can only refer to named inputs of their own project");
804 }
805 let pattern = object["fileset"]
806 .as_str()
807 .context("fileset input must be a glob")?
808 .to_owned();
809 for dependency in self.dependency_closure(project) {
810 self.fileset(&dependency, &pattern)?;
811 }
812 }
813 Value::Object(object)
814 if object.len() == 1 && object.contains_key("workingDirectory") =>
815 {
816 let mode = object["workingDirectory"].as_str().unwrap_or_default();
817 let directory = std::env::current_dir()?;
818 let value = match mode {
819 "absolute" => directory.to_string_lossy().into_owned(),
820 "relative" => {
821 let root = self.workspace.root.canonicalize()?;
822 let directory = directory.canonicalize()?;
823 paths::relative(&root, &directory)
824 .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
825 }
826 _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
827 };
828 self.values
829 .insert(format!("workingDirectory:{mode}"), json!(value));
830 }
831 Value::Object(object) if object.contains_key("json") => {
832 self.json(project, object)?;
833 }
834 Value::String(name)
835 if !name.contains("{projectRoot}") && !name.contains("{workspaceRoot}") =>
836 {
837 self.named(project, name)?
838 }
839 Value::Object(object) if object.len() == 1 && object.contains_key("fileset") => {
840 let pattern = object["fileset"]
841 .as_str()
842 .context("fileset input must be a glob")?;
843 self.fileset(project, pattern)?;
844 }
845 Value::String(pattern) => self.fileset(project, pattern)?,
846 Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
847 let name = object["env"]
848 .as_str()
849 .context("env input must name a variable")?;
850 let Some(prepared) = self.prepared else {
851 self.values.insert(format!("env:{name}"), Value::Null);
852 return Ok(());
853 };
854 let value = prepared
855 .env
856 .get(std::ffi::OsStr::new(name))
857 .map(|value| {
858 value
859 .to_str()
860 .map(str::to_owned)
861 .context("declared env input must be UTF-8")
862 })
863 .transpose()?;
864 self.values.insert(format!("env:{name}"), json!(value));
865 }
866 Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
867 let command = object["runtime"]
868 .as_str()
869 .context("runtime input must be a command")?;
870 let Some(prepared) = self.prepared else {
871 self.values
872 .insert(format!("runtime:{command}"), Value::Null);
873 return Ok(());
874 };
875 let memo = RuntimeKey {
876 command: command.into(),
877 environment: prepared.runtime_env.clone(),
878 };
879 let slot = self
880 .snapshot
881 .runtime
882 .lock()
883 .unwrap()
884 .entry(memo)
885 .or_default()
886 .clone();
887 let mut saved = slot.lock().unwrap();
888 if let Some(value) = saved.as_ref() {
889 self.values
890 .insert(format!("runtime:{command}"), value.clone());
891 return Ok(());
892 }
893 let _profile = crate::profile::span(command, "runtime");
894 let mut prepared = prepared.clone();
895 prepared.env = prepared.runtime_env.clone();
896 prepared.execution.clear();
897 prepared.node_path = None;
898 prepared.commands = vec![command.into()];
899 prepared.cwd = self.workspace.root.clone();
900 prepared.parallel = false;
901 let log = tempfile::NamedTempFile::new()?;
902 let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
903 if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
904 {
905 bail!("runtime input did not succeed");
906 }
907 let mut stdout = blake3::Hasher::new();
908 let mut stderr = blake3::Hasher::new();
909 read_capture(File::open(log.path())?, |error, bytes| {
910 if error {
911 stderr.update(bytes);
912 } else {
913 stdout.update(bytes);
914 }
915 Ok(())
916 })?;
917 let value = json!([
918 stdout.finalize().to_hex().to_string(),
919 stderr.finalize().to_hex().to_string()
920 ]);
921 *saved = Some(value.clone());
922 self.values.insert(format!("runtime:{command}"), value);
923 }
924 Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
925 if object
926 .keys()
927 .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
928 {
929 bail!("unsupported output input field");
930 }
931 let pattern = object["dependentTasksOutputFiles"]
932 .as_str()
933 .context("dependentTasksOutputFiles must be a glob")?;
934 self.snapshot.pattern(pattern, false)?;
935 let transitive = object
936 .get("transitive")
937 .map(|value| value.as_bool().context("transitive must be boolean"))
938 .transpose()?
939 .unwrap_or(false);
940 let value = self
944 .values
945 .entry(format!("dependentTasksOutputFiles:{pattern}"))
946 .or_insert(json!(false));
947 if transitive {
948 *value = json!(true);
949 }
950 }
951 Value::Object(object)
952 if object.len() == 1 && object.contains_key("externalDependencies") =>
953 {
954 let names: Vec<String> =
955 serde_json::from_value(object["externalDependencies"].clone())?;
956 self.external.extend(names);
957 }
958 _ => bail!("unsupported task input declaration"),
959 }
960 Ok(())
961 }
962
963 fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
964 let (exclude, pattern) = pattern
965 .strip_prefix('!')
966 .map(|pattern| (true, pattern))
967 .unwrap_or((false, pattern));
968 let scope = if pattern.contains("{workspaceRoot}") {
970 FileScope::Workspace
971 } else {
972 FileScope::Project(project.to_owned())
973 };
974 let pattern = paths::expand(self.workspace, project, pattern)?;
975 let matcher = self.snapshot.pattern(&pattern, exclude)?;
976 let prefix = pattern
977 .split('/')
978 .take_while(|part| is_literal(part))
979 .collect::<Vec<_>>()
980 .join("/");
981 let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
982 Box::new(self.snapshot.files.iter())
983 } else {
984 Box::new(under(&self.snapshot.files, &prefix))
985 };
986 let selection = self.scopes.entry(scope).or_default();
987 let selected = if exclude {
988 &mut selection.excluded
989 } else {
990 &mut selection.included
991 };
992 selected.extend(candidates.filter(|file| matcher.is_match(file)).cloned());
993 selection.patterns.push(FilePattern {
994 matcher,
995 prefix,
996 excluded: exclude,
997 });
998 Ok(())
999 }
1000
1001 fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
1003 let mut closure = BTreeSet::new();
1004 let mut pending = vec![project.to_owned()];
1005 while let Some(current) = pending.pop() {
1006 for edge in &self.snapshot.projects.dependencies[¤t] {
1007 if edge.target != project && closure.insert(edge.target.clone()) {
1008 pending.push(edge.target.clone());
1009 }
1010 }
1011 }
1012 closure
1013 }
1014
1015 fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
1017 for dependency in self.dependency_closure(project) {
1018 if self.expanded.insert((dependency.clone(), name.to_owned())) {
1019 self.named(&dependency, name)?;
1020 }
1021 }
1022 Ok(())
1023 }
1024
1025 fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
1029 for key in object.keys() {
1030 if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
1031 bail!("unsupported json input field {key:?}");
1032 }
1033 }
1034 let path = object["json"]
1035 .as_str()
1036 .context("json input must name a file")?;
1037 let path = paths::expand(self.workspace, project, path)?;
1038 paths::validate_path(&path)?;
1039 let list = |name: &str| -> Result<Option<Vec<String>>> {
1040 object
1041 .get(name)
1042 .map(|value| {
1043 serde_json::from_value(value.clone())
1044 .with_context(|| format!("json input {name} must be an array of strings"))
1045 })
1046 .transpose()
1047 };
1048 let fields = list("fields")?;
1049 let exclude = list("excludeFields")?.unwrap_or_default();
1050 let value = match std::fs::read_to_string(self.workspace.root.join(&path)) {
1051 Ok(text) => {
1052 let mut value: Value =
1053 jsonc_parser::parse_to_serde_value(&text, &Default::default())
1054 .with_context(|| format!("cannot parse json input {path}"))?;
1055 if let Some(fields) = &fields {
1056 let mut kept = json!({});
1057 for field in fields {
1058 if let Some(found) = json_path(&value, field) {
1059 set_json_path(&mut kept, field, found.clone());
1060 }
1061 }
1062 value = kept;
1063 }
1064 for field in &exclude {
1065 remove_json_path(&mut value, field);
1066 }
1067 value
1068 }
1069 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Null,
1070 Err(error) => return Err(error.into()),
1071 };
1072 self.values.insert(format!("json:{path}"), value);
1073 Ok(())
1074 }
1075
1076 fn named(&mut self, project: &str, name: &str) -> Result<()> {
1077 let identity = (project.to_owned(), name.to_owned());
1078 if self.named_stack.contains(&identity) {
1079 bail!("named input cycle at {project}:{name}");
1080 }
1081 self.named_stack.push(identity);
1082 let inputs = self.workspace.projects[project]
1083 .named_inputs
1084 .get(name)
1085 .cloned()
1086 .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
1087 .with_context(|| format!("unknown named input {name:?}"))?;
1088 for input in &inputs {
1089 self.input(project, input)?;
1090 }
1091 self.named_stack.pop();
1092 Ok(())
1093 }
1094}
1095
1096pub fn resolve(
1097 snapshot: &Snapshot,
1098 workspace: &Workspace,
1099 task: &Task,
1100 prepared: Option<&PreparedTask>,
1101 cancelled: &AtomicBool,
1102) -> Result<Resolved> {
1103 let mut resolver = Resolver {
1104 workspace,
1105 snapshot,
1106 selected: BTreeSet::new(),
1107 scopes: BTreeMap::new(),
1108 values: BTreeMap::new(),
1109 named_stack: Vec::new(),
1110 expanded: BTreeSet::new(),
1111 external: BTreeSet::new(),
1112 prepared,
1113 cancelled,
1114 };
1115 let default = vec![json!("default"), json!("^default")];
1116 for input in task.definition.inputs.as_ref().unwrap_or(&default) {
1117 resolver.input(&task.project, input)?;
1118 }
1119 let mut upstream = BTreeSet::new();
1122 let mut pending: Vec<_> = task.dependencies.iter().cloned().collect();
1123 while let Some(id) = pending.pop() {
1124 if upstream.insert(id.clone())
1125 && let Some(dependencies) = snapshot.task_dependencies.get(&id)
1126 {
1127 pending.extend(dependencies.iter().cloned());
1128 }
1129 }
1130 let own_outputs = Outputs::new(workspace, task)
1131 .ok()
1132 .filter(Outputs::is_explicit);
1133 for (producer, declared) in &snapshot.generated_tasks {
1134 if producer.id == task.id || !upstream.contains(&producer.id) {
1135 continue;
1136 }
1137 let overlaps = |a: &str, b: &str| {
1138 a.is_empty()
1139 || a == b
1140 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1141 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1142 };
1143 let relevant = resolver
1144 .scopes
1145 .values()
1146 .flat_map(|scope| &scope.patterns)
1147 .filter(|pattern| !pattern.excluded)
1148 .any(|pattern| {
1149 declared.anchors().any(|anchor| {
1150 overlaps(&pattern.prefix, anchor)
1151 && (!snapshot.source_ignore.matches_directory(anchor)
1152 || !workspace.root.join(anchor).is_dir())
1153 })
1154 });
1155 if !relevant {
1156 continue;
1157 }
1158 let outputs = snapshot.dependency_outputs(workspace, producer)?;
1159 for path in &outputs.files {
1160 if snapshot.source_ignore.matches(path)
1161 || own_outputs.as_ref().is_some_and(|own| own.matches(path))
1162 {
1163 continue;
1164 }
1165 for selection in resolver.scopes.values_mut() {
1166 for pattern in &selection.patterns {
1167 if pattern.matcher.is_match(path) {
1168 if pattern.excluded {
1169 selection.excluded.insert(path.clone());
1170 } else {
1171 selection.included.insert(path.clone());
1172 }
1173 }
1174 }
1175 }
1176 }
1177 }
1178 for selection in resolver.scopes.values() {
1179 resolver.selected.extend(
1180 selection
1181 .included
1182 .difference(&selection.excluded)
1183 .filter(|path| own_outputs.as_ref().is_none_or(|own| !own.matches(path)))
1184 .cloned(),
1185 );
1186 }
1187 let readable = snapshot
1188 .installed(&workspace.root)?
1189 .is_some_and(|installed| installed.lockfile.is_some());
1190 let tsconfig = ["tsconfig.base.json", "tsconfig.json"]
1196 .into_iter()
1197 .find(|name| {
1198 workspace.root.join(name).is_file() || snapshot.extra_candidates.contains(*name)
1199 });
1200 let mut mandatory = BTreeSet::new();
1201 for path in tsconfig.into_iter().chain([
1202 "nx.json",
1203 ".gitignore",
1204 ".nxignore",
1205 qk_config::LOCAL_WORKSPACE,
1206 "package.json",
1207 "pnpm-workspace.yaml",
1208 "pnpm-lock.yaml",
1209 "package-lock.json",
1210 "yarn.lock",
1211 "bun.lock",
1212 ]) {
1213 if (workspace.root.join(path).is_file() || snapshot.extra_candidates.contains(path))
1214 && !(path == "pnpm-lock.yaml" && readable)
1215 {
1216 resolver.selected.insert(path.into());
1217 mandatory.insert(path.into());
1218 }
1219 }
1220 if let Some(extended) = &workspace.extended {
1221 resolver.selected.insert(extended.clone());
1222 mandatory.insert(extended.clone());
1223 }
1224 let mut packages = BTreeSet::from([task.project.clone()]);
1226 let mut pending = packages.clone();
1227 while let Some(project) = pending.pop_first() {
1228 for edge in &snapshot.projects.dependencies[&project] {
1229 if packages.insert(edge.target.clone()) {
1230 pending.insert(edge.target.clone());
1231 }
1232 }
1233 }
1234 for project in &packages {
1235 for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
1236 let path = Path::new(&workspace.projects[project].root).join(name);
1237 let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
1238 if workspace.root.join(&path).is_file() {
1239 let path = path
1240 .to_str()
1241 .context("project path must be UTF-8")?
1242 .replace('\\', "/");
1243 resolver.selected.insert(path.clone());
1244 mandatory.insert(path);
1245 }
1246 }
1247 }
1248 let workspace_file = readable && resolver.selected.remove("pnpm-workspace.yaml");
1251 if workspace_file {
1252 mandatory.remove("pnpm-workspace.yaml");
1253 }
1254 let lockfile = readable.then(|| {
1255 let importers = std::iter::once(".".to_owned())
1258 .chain(
1259 packages
1260 .iter()
1261 .map(|project| workspace.projects[project].root.clone()),
1262 )
1263 .collect();
1264 (importers, resolver.external.clone())
1265 });
1266 Ok(Resolved {
1267 files: resolver.selected,
1268 mandatory,
1269 values: resolver.values,
1270 lockfile,
1271 workspace_file,
1272 external: resolver.external,
1273 })
1274}
1275
1276pub fn inputs(
1279 snapshot: &Snapshot,
1280 workspace: &Workspace,
1281 task: &Task,
1282 prepared: &PreparedTask,
1283 dependencies: &BTreeMap<String, String>,
1284 cancelled: &AtomicBool,
1285) -> Result<Value> {
1286 let Resolved {
1287 files,
1288 mut values,
1289 lockfile,
1290 workspace_file,
1291 ..
1292 } = resolve(snapshot, workspace, task, Some(prepared), cancelled)?;
1293 let installed = snapshot.installed(&workspace.root)?;
1294 if let (Some((importers, external)), Some(installed)) = (lockfile, &installed)
1295 && let Some(lockfile) = &installed.lockfile
1296 {
1297 let importers: BTreeMap<_, _> = importers
1298 .iter()
1299 .filter(|importer| lockfile.has_importer(importer))
1300 .map(|importer| {
1301 let digest = installed.digest(&format!("importer:{importer}"), || {
1302 lockfile.installed(importer).unwrap_or_default()
1303 });
1304 (importer.clone(), digest)
1305 })
1306 .collect();
1307 let external: BTreeMap<_, _> = external
1308 .iter()
1309 .map(|name| {
1310 let digest =
1311 installed.digest(&format!("package:{name}"), || lockfile.package(name));
1312 (name.clone(), digest)
1313 })
1314 .collect();
1315 values.insert(
1316 "lockfile".into(),
1317 json!({
1318 "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
1319 "importers": importers,
1320 "external": external,
1321 }),
1322 );
1323 }
1324 let mut files = files
1325 .iter()
1326 .map(|path| {
1327 let value = snapshot
1328 .file_value(&workspace.root, path)
1329 .with_context(|| format!("cannot read input {path}"))?;
1330 Ok((path.clone(), value))
1331 })
1332 .collect::<Result<BTreeMap<_, _>>>()?;
1333 if workspace_file {
1334 match workspace_without_resolution(&workspace.root) {
1335 Some(value) => {
1336 values.insert("pnpm-workspace".into(), value);
1337 }
1338 None => {
1339 files.insert(
1340 "pnpm-workspace.yaml".into(),
1341 snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
1342 );
1343 }
1344 }
1345 }
1346 let mut definition = task.definition.clone();
1348 definition.extra.remove("qk:warm");
1349 definition.extra.remove("qk:threads");
1350 let hash = json!({
1351 "schema":"qk-local-v2", "qk":env!("CARGO_PKG_VERSION"),
1352 "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
1353 "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
1354 "files":files, "values":values, "dependencies":dependencies,
1355 });
1356 Ok(hash)
1357}
1358
1359pub fn key(inputs: &Value) -> Result<String> {
1361 Ok(blake3::hash(&serde_json::to_vec(inputs)?)
1362 .to_hex()
1363 .to_string())
1364}
1365
1366pub fn fingerprint(
1367 snapshot: &Snapshot,
1368 workspace: &Workspace,
1369 task: &Task,
1370 prepared: &PreparedTask,
1371 dependencies: &BTreeMap<String, String>,
1372 cancelled: &AtomicBool,
1373) -> Result<String> {
1374 key(&inputs(
1375 snapshot,
1376 workspace,
1377 task,
1378 prepared,
1379 dependencies,
1380 cancelled,
1381 )?)
1382}
1383
1384pub(crate) fn dependency_keys(
1388 snapshot: &Snapshot,
1389 workspace: &Workspace,
1390 graph: &TaskGraph,
1391 task: &Task,
1392 dependencies: &BTreeMap<String, String>,
1393 cancelled: &AtomicBool,
1394) -> Result<BTreeMap<String, String>> {
1395 dependency_keys_impl(
1396 snapshot,
1397 workspace,
1398 graph,
1399 task,
1400 dependencies,
1401 cancelled,
1402 false,
1403 )
1404}
1405
1406pub(crate) fn recheck_dependency_keys(
1408 snapshot: &Snapshot,
1409 workspace: &Workspace,
1410 graph: &TaskGraph,
1411 task: &Task,
1412 dependencies: &BTreeMap<String, String>,
1413 cancelled: &AtomicBool,
1414) -> Result<BTreeMap<String, String>> {
1415 dependency_keys_impl(
1416 snapshot,
1417 workspace,
1418 graph,
1419 task,
1420 dependencies,
1421 cancelled,
1422 true,
1423 )
1424}
1425
1426fn dependency_keys_impl(
1427 snapshot: &Snapshot,
1428 workspace: &Workspace,
1429 graph: &TaskGraph,
1430 task: &Task,
1431 dependencies: &BTreeMap<String, String>,
1432 cancelled: &AtomicBool,
1433 fresh: bool,
1434) -> Result<BTreeMap<String, String>> {
1435 let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
1436 let selections: Vec<_> = resolved.dependency_output_inputs().collect();
1437 if selections.is_empty() {
1438 if fresh {
1439 return dependencies
1440 .iter()
1441 .map(|(id, key)| {
1442 let outputs = Outputs::new(workspace, &graph.tasks[id])?;
1443 let key = if outputs.declared() {
1444 crate::output_fingerprint(&workspace.root, &outputs, "")?
1445 } else {
1446 key.clone()
1447 };
1448 Ok((id.clone(), key))
1449 })
1450 .collect();
1451 }
1452 return Ok(dependencies.clone());
1453 }
1454 let mut keys = BTreeMap::new();
1455 let mut fresh_outputs = HashMap::new();
1456 for (pattern, transitive) in selections {
1457 let matcher = snapshot.pattern(pattern, false)?;
1458 let mut selected = task.dependencies.clone();
1459 if transitive {
1460 let mut pending = selected.clone();
1461 while let Some(id) = pending.pop_first() {
1462 for dependency in &graph.tasks[&id].dependencies {
1463 if selected.insert(dependency.clone()) {
1464 pending.insert(dependency.clone());
1465 }
1466 }
1467 }
1468 }
1469 for id in selected {
1470 let outputs = if fresh {
1471 if !fresh_outputs.contains_key(&id) {
1472 fresh_outputs.insert(
1473 id.clone(),
1474 Arc::new(snapshot.fresh_dependency_outputs(workspace, &graph.tasks[&id])?),
1475 );
1476 }
1477 fresh_outputs[&id].clone()
1478 } else {
1479 snapshot.dependency_outputs(workspace, &graph.tasks[&id])?
1480 };
1481 let mut files = BTreeMap::new();
1482 for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
1483 let known = outputs.values.lock().unwrap().get(path).cloned();
1484 let value = match known {
1485 Some(value) => value,
1486 None => {
1487 let value = snapshot.file_value(&workspace.root, path)?;
1488 if value.get("link").is_none() {
1490 outputs
1491 .values
1492 .lock()
1493 .unwrap()
1494 .insert(path.clone(), value.clone());
1495 }
1496 value
1497 }
1498 };
1499 files.insert(path.clone(), value);
1500 }
1501 keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
1502 }
1503 }
1504 Ok(keys)
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510 use qk_taskgraph::Request;
1511
1512 fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
1513 let root = tempfile::tempdir().unwrap();
1514 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1515 let mut post = json!({"command": "true"});
1516 if let Some(outputs) = outputs {
1517 post["outputs"] = outputs;
1518 }
1519 std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
1520 "gen": {"command":"true", "outputs":["generated"]},
1521 "post": post,
1522 "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
1523 }}).to_string()).unwrap();
1524 std::fs::create_dir(root.path().join("generated")).unwrap();
1525 std::fs::write(root.path().join("generated/value"), "one").unwrap();
1526 let workspace = Workspace::load(root.path()).unwrap();
1527 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1528 (root, workspace, graph)
1529 }
1530
1531 #[test]
1532 fn undeclared_writes_invalidate_dependency_values() {
1533 for outputs in [None, Some(json!(["generated"]))] {
1534 let (root, workspace, graph) = fixture(outputs);
1535 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1536 let keys = || {
1537 dependency_keys(
1538 &snapshot,
1539 &workspace,
1540 &graph,
1541 &graph.tasks["app:build"],
1542 &BTreeMap::new(),
1543 &AtomicBool::new(false),
1544 )
1545 .unwrap()
1546 };
1547 let before = keys();
1548 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1549 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1550 assert_ne!(before, keys());
1551 }
1552 }
1553
1554 #[cfg(unix)]
1555 #[test]
1556 fn dependency_symlinks_recheck_targets_outside_output_roots() {
1557 let (root, mut workspace, _) = fixture(Some(json!(["generated"])));
1558 workspace
1559 .projects
1560 .get_mut("app")
1561 .unwrap()
1562 .targets
1563 .get_mut("gen")
1564 .unwrap()
1565 .outputs = Some(vec!["links".into()]);
1566 std::fs::create_dir(root.path().join("links")).unwrap();
1567 std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
1568 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1569 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1570 let keys = || {
1571 dependency_keys(
1572 &snapshot,
1573 &workspace,
1574 &graph,
1575 &graph.tasks["app:build"],
1576 &BTreeMap::new(),
1577 &AtomicBool::new(false),
1578 )
1579 .unwrap()
1580 };
1581 let before = keys();
1582 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1583 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1584 assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
1585 }
1586}