1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::fs::File;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::AtomicBool;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, SystemTime};
8
9use anyhow::{Context, Result, bail};
10use qk_config::Workspace;
11use qk_executor::{Capture, Display, Outcome, PreparedTask, execute_captured, read_capture};
12use qk_graph::ProjectGraph;
13use qk_lockfile::Lockfile;
14use qk_taskgraph::{Task, TaskGraph};
15use serde_json::{Value, json};
16
17use crate::glob::is_literal;
18use crate::paths::{self, Outputs, Pattern};
19
20pub fn digest_file(path: &Path) -> Result<String> {
21 let mut hasher = blake3::Hasher::new();
22 let mut reader = File::open(path)?;
23 let mut buffer = [0; 64 * 1024];
24 loop {
25 let count = reader.read(&mut buffer)?;
26 if count == 0 {
27 break;
28 }
29 hasher.update(&buffer[..count]);
30 }
31 Ok(hasher.finalize().to_hex().to_string())
32}
33
34#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
37struct Stamp(Vec<i64>);
38
39impl Stamp {
40 fn new(metadata: &std::fs::Metadata) -> Self {
41 let modified = metadata
42 .modified()
43 .ok()
44 .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
45 .map_or(-1, |duration| duration.as_nanos() as i64);
46 #[allow(unused_mut)]
47 let mut values = vec![metadata.len() as i64, modified];
48 #[cfg(unix)]
49 {
50 use std::os::unix::fs::MetadataExt;
51 values.extend([
52 metadata.ctime(),
53 metadata.ctime_nsec(),
54 metadata.ino() as i64,
55 i64::from(metadata.mode()),
56 ]);
57 }
58 Self(values)
59 }
60
61 fn settled(&self) -> bool {
64 let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
65 self.0[1] >= 0
66 && SystemTime::now()
67 .duration_since(modified)
68 .is_ok_and(|age| age > Duration::from_secs(2))
69 }
70}
71
72fn digests_path(root: &Path) -> PathBuf {
76 paths::worktree_state(root).join("digests.json")
77}
78
79pub struct Snapshot {
83 pub(crate) files: BTreeSet<String>,
84 projects: ProjectGraph,
85 canonical_root: PathBuf,
86 workspace_prefix: Option<PathBuf>,
87 patterns: Mutex<HashMap<(String, bool), Arc<Pattern>>>,
88 digests: Mutex<HashMap<String, (Stamp, String)>>,
89 digests_changed: std::sync::atomic::AtomicBool,
91 directories: Mutex<std::collections::HashSet<PathBuf>>,
93 runtime: Mutex<HashMap<String, Value>>,
95 lockfile: Mutex<Option<Arc<Installed>>>,
97 dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
100 outputs_written: std::sync::atomic::AtomicU64,
102}
103
104struct DependencyOutputs {
107 anchors: Vec<String>,
108 files: Vec<String>,
109 values: Mutex<HashMap<String, Value>>,
110}
111
112struct Installed {
115 content: String,
116 lockfile: Option<Lockfile>,
118 digests: Mutex<HashMap<String, String>>,
119}
120
121impl Installed {
122 fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
123 if let Some(digest) = self.digests.lock().unwrap().get(key) {
124 return digest.clone();
125 }
126 let mut hasher = blake3::Hasher::new();
127 for fingerprint in fingerprints() {
128 hasher.update(fingerprint.as_bytes());
129 hasher.update(b"\n");
130 }
131 let digest = hasher.finalize().to_hex().to_string();
132 self.digests
133 .lock()
134 .unwrap()
135 .insert(key.to_owned(), digest.clone());
136 digest
137 }
138}
139
140impl Snapshot {
141 pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
142 let (files, lockfile) = std::thread::scope(|scope| {
145 let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
146 let files = source_files(&workspace.root);
147 (files, lockfile.join().expect("lockfile thread panicked"))
148 });
149 let mut files = files?;
150 if let Ok(cache_relative) = cache_path.strip_prefix(&workspace.root) {
151 files.retain(|path| !Path::new(path).starts_with(cache_relative));
152 }
153 let mut generated = BTreeSet::new();
157 for outputs in graph
158 .tasks
159 .values()
160 .filter_map(|task| Outputs::new(workspace, task).ok())
161 .filter(Outputs::is_explicit)
162 {
163 for anchor in outputs.anchors() {
164 generated.extend(
165 under(&files, anchor)
166 .filter(|path| outputs.matches(path))
167 .cloned(),
168 );
169 }
170 }
171 for task in graph.tasks.values() {
173 let Ok(Some(warm)) = crate::warm::config(workspace, task) else {
174 continue;
175 };
176 if let Ok(paths) = Outputs::from_paths(&warm.kept_paths()) {
177 for anchor in paths.anchors() {
178 generated.extend(
179 under(&files, anchor)
180 .filter(|path| paths.matches(path))
181 .cloned(),
182 );
183 }
184 }
185 }
186 files.retain(|path| !generated.contains(path));
187 let canonical_root = workspace.root.canonicalize()?;
188 let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
189 .and_then(|root| root.canonicalize().ok())
190 .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
191 Ok(Self {
192 files,
193 projects: ProjectGraph::build(workspace)?,
194 canonical_root,
195 workspace_prefix,
196 patterns: Mutex::default(),
197 digests: Mutex::new(load_digests(&workspace.root)),
198 digests_changed: Default::default(),
199 directories: Mutex::default(),
200 runtime: Mutex::default(),
201 lockfile: Mutex::new(lockfile),
202 dependency_outputs: Mutex::default(),
203 outputs_written: Default::default(),
204 })
205 }
206
207 pub fn save_digests(&self, root: &Path) {
209 if !self
210 .digests_changed
211 .load(std::sync::atomic::Ordering::Relaxed)
212 {
213 return;
214 }
215 let digests = self.digests.lock().unwrap();
216 let entries: BTreeMap<&String, (&Stamp, &String)> = digests
217 .iter()
218 .filter(|(path, _)| self.files.contains(*path))
219 .map(|(path, (stamp, digest))| (path, (stamp, digest)))
220 .collect();
221 let path = digests_path(root);
222 let saved = (|| -> Result<()> {
223 let directory = path.parent().context("digests have a directory")?;
224 std::fs::create_dir_all(directory)?;
225 let mut file = tempfile::NamedTempFile::new_in(directory)?;
226 serde_json::to_writer(
227 std::io::BufWriter::new(file.as_file_mut()),
228 &json!({"version": 1, "entries": entries}),
229 )?;
230 file.persist(&path)?;
231 Ok(())
232 })();
233 if let Err(error) = saved {
234 qk_executor::status!("qk: could not save file digests: {error:#}");
235 }
236 }
237
238 fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
240 paths::validate_path(path)?;
241 let mut current = root.to_owned();
242 for part in Path::new(path)
243 .parent()
244 .into_iter()
245 .flat_map(Path::components)
246 {
247 current.push(part);
248 if self.directories.lock().unwrap().contains(¤t) {
249 continue;
250 }
251 match std::fs::symlink_metadata(¤t) {
252 Ok(metadata) if metadata.file_type().is_symlink() => {
253 bail!("input parent is a symlink: {}", current.display())
254 }
255 Ok(metadata) if !metadata.is_dir() => {
256 bail!("input parent is not a directory: {}", current.display())
257 }
258 Ok(_) => {
259 self.directories.lock().unwrap().insert(current.clone());
260 }
261 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
262 Err(error) => return Err(error.into()),
263 }
264 }
265 Ok(())
266 }
267
268 fn installed(&self, root: &Path) -> Result<Option<Arc<Installed>>> {
269 let absolute = root.join("pnpm-lock.yaml");
270 let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
271 return Ok(None);
272 };
273 if !metadata.is_file() {
274 return Ok(None);
275 }
276 let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
277 let mut current = self.lockfile.lock().unwrap();
278 if let Some(installed) = &*current
279 && installed.content == content
280 {
281 return Ok(Some(installed.clone()));
282 }
283 let Some(installed) = parse_lockfile(&absolute) else {
284 return Ok(None);
285 };
286 *current = Some(installed.clone());
287 Ok(Some(installed))
288 }
289
290 pub fn with_candidates(mut self, paths: &[String]) -> Self {
293 self.files.extend(paths.iter().cloned());
294 self
295 }
296
297 fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
298 let key = (pattern.to_owned(), negated);
299 if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
300 return Ok(pattern.clone());
301 }
302 let compiled = Arc::new(Pattern::new(pattern, negated)?);
303 self.patterns.lock().unwrap().insert(key, compiled.clone());
304 Ok(compiled)
305 }
306
307 fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
308 let stamp = Stamp::new(metadata);
309 if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
310 && *known == stamp
311 {
312 return Ok(digest.clone());
313 }
314 let digest = digest_file(absolute)?;
315 let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
317 if after == stamp && stamp.settled() {
318 self.digests
319 .lock()
320 .unwrap()
321 .insert(path.to_owned(), (stamp, digest.clone()));
322 self.digests_changed
323 .store(true, std::sync::atomic::Ordering::Relaxed);
324 }
325 Ok(digest)
326 }
327
328 fn dependency_outputs(
331 &self,
332 workspace: &Workspace,
333 task: &Task,
334 ) -> Result<Arc<DependencyOutputs>> {
335 if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
336 return Ok(outputs.clone());
337 }
338 let written = self
339 .outputs_written
340 .load(std::sync::atomic::Ordering::SeqCst);
341 let declared = Outputs::new(workspace, task)?;
342 let mut files = Vec::new();
343 for entry in declared.entries(&workspace.root)? {
344 let (path, metadata) = entry?;
345 if !metadata.is_dir() {
346 files.push(path);
347 }
348 }
349 let outputs = Arc::new(DependencyOutputs {
350 anchors: declared.anchors().map(str::to_owned).collect(),
351 files,
352 values: Mutex::default(),
353 });
354 let mut listed = self.dependency_outputs.lock().unwrap();
355 if self
356 .outputs_written
357 .load(std::sync::atomic::Ordering::SeqCst)
358 != written
359 {
360 return Ok(outputs);
361 }
362 Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
363 }
364
365 pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
368 let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
369 .ok()
370 .filter(Outputs::is_explicit)
371 .map(|outputs| outputs.anchors().map(str::to_owned).collect());
372 let overlap = |a: &str, b: &str| {
373 a == b
374 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
375 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
376 };
377 let mut listed = self.dependency_outputs.lock().unwrap();
378 self.outputs_written
379 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
380 listed.retain(|_, outputs| {
381 anchors.as_ref().is_some_and(|written| {
382 !written
383 .iter()
384 .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
385 })
386 });
387 }
388
389 fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
390 self.safe_parents(root, path)?;
391 let absolute = root.join(path);
392 let metadata = std::fs::symlink_metadata(&absolute)?;
393 if metadata.file_type().is_symlink() {
394 let target = std::fs::read_link(&absolute)?;
395 let Ok(resolved) = absolute.canonicalize() else {
397 return Ok(json!({"link":target, "dangling":true}));
398 };
399 let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
400 bail!("input symlink resolves outside the workspace: {path}");
401 };
402 if resolved.is_file() {
403 return Ok(json!({"link":target, "content":digest_file(&resolved)?}));
404 }
405 let relative = paths::relative(Path::new(""), relative)?;
408 if relative.is_empty() {
409 bail!("input symlink resolves to the workspace root: {path}");
410 }
411 let mut contents = BTreeMap::new();
412 for file in under(&self.files, &relative) {
413 let absolute = self.canonical_root.join(file);
414 let metadata = std::fs::symlink_metadata(&absolute)?;
415 let value = if metadata.file_type().is_symlink() {
416 json!({"link": std::fs::read_link(&absolute)?})
417 } else {
418 json!(self.digest(file, &absolute, &metadata)?)
419 };
420 contents.insert(file.strip_prefix(&relative).unwrap_or(file), value);
421 }
422 return Ok(json!({"link":target, "directory":contents}));
423 }
424 if !metadata.is_file() {
425 bail!("input is not a regular file: {path}");
426 }
427 let mode = crate::store::mode(&metadata) & 0o111 != 0;
430 Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
431 }
432}
433
434fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
437 let text = std::fs::read_to_string(path).ok()?;
438 let lockfile = match Lockfile::parse(&text) {
439 Ok(lockfile) => Some(lockfile),
440 Err(error) => {
441 qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
442 None
443 }
444 };
445 Some(Arc::new(Installed {
446 content: blake3::hash(text.as_bytes()).to_hex().to_string(),
447 lockfile,
448 digests: Mutex::default(),
449 }))
450}
451
452fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
453 #[derive(serde::Deserialize)]
454 struct Saved {
455 version: u32,
456 entries: HashMap<String, (Stamp, String)>,
457 }
458 std::fs::read(digests_path(root))
459 .ok()
460 .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
461 .filter(|saved| saved.version == 1)
462 .map(|saved| saved.entries)
463 .unwrap_or_default()
464}
465
466fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
468 files
469 .range::<str, _>((
470 std::ops::Bound::Included(prefix),
471 std::ops::Bound::Unbounded,
472 ))
473 .take_while(move |path| path.starts_with(prefix))
474 .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
475}
476
477pub struct SourceIgnore(ignore::gitignore::Gitignore);
479
480impl SourceIgnore {
481 pub fn new(root: &Path) -> Result<Self> {
483 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
484 for name in [".gitignore", ".nxignore"] {
485 let path = root.join(name);
486 if path.is_file()
487 && let Some(error) = builder.add(path)
488 {
489 return Err(error.into());
490 }
491 }
492 Ok(Self(builder.build()?))
493 }
494
495 pub fn matches(&self, path: &str) -> bool {
497 Path::new(path)
499 .ancestors()
500 .skip(1)
501 .take_while(|parent| !parent.as_os_str().is_empty())
502 .any(|parent| self.0.matched(parent, true).is_ignore())
503 || self.0.matched(path, false).is_ignore()
504 }
505}
506
507pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
509 let lines =
510 |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
511 let output = output.ok().filter(|output| output.status.success())?;
512 Some(
513 output
514 .stdout
515 .split(|byte| *byte == 0)
516 .filter(|path| !path.is_empty())
517 .map(|path| {
518 String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
519 })
520 .collect(),
521 )
522 };
523 let (listed, deleted) = if root.join(".nxignore").is_file() {
528 (None, None)
531 } else {
532 std::thread::scope(|scope| {
533 let deleted =
534 scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
535 let listed = paths::git(
536 root,
537 &[
538 "ls-files",
539 "-v",
540 "--cached",
541 "--others",
542 "--exclude-standard",
543 "-z",
544 "--",
545 ".",
546 ],
547 );
548 (
549 lines(listed),
550 lines(deleted.join().expect("git listing thread panicked")),
551 )
552 })
553 };
554 let candidates = match (listed, deleted) {
555 (Some(listed), Some(deleted)) => {
556 let deleted = deleted?;
557 let mut files = BTreeSet::new();
558 for entry in listed? {
559 let Some((tag, path)) = entry.split_once(' ') else {
560 bail!("unexpected git ls-files entry {entry:?}");
561 };
562 let absent =
563 matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
564 if !absent && !deleted.contains(path) {
565 files.insert(path.to_owned());
566 }
567 }
568 files
569 }
570 _ => {
571 let mut files = BTreeSet::new();
572 for entry in ignore::WalkBuilder::new(root)
573 .hidden(false)
574 .parents(false)
575 .git_global(false)
576 .require_git(false)
577 .ignore(false)
578 .add_custom_ignore_filename(".nxignore")
579 .follow_links(false)
580 .filter_entry(|entry| {
581 !matches!(
582 entry.file_name().to_str(),
583 Some(".git" | ".qk" | "node_modules")
584 )
585 })
586 .build()
587 {
588 let entry = entry?;
589 if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
590 files.insert(paths::relative(root, entry.path())?);
591 }
592 }
593 files
594 }
595 };
596 let ignore = SourceIgnore::new(root)?;
597 Ok(candidates
598 .into_iter()
599 .filter(|path| {
600 !ignore.matches(path)
601 && !path
602 .split('/')
603 .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
604 })
605 .collect())
606}
607
608fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
609 path.split('.')
610 .try_fold(value, |value, part| value.get(part))
611}
612
613fn set_json_path(target: &mut Value, path: &str, value: Value) {
614 let mut parts = path.split('.').peekable();
615 let mut current = target;
616 while let Some(part) = parts.next() {
617 let Value::Object(object) = current else {
618 return;
619 };
620 if parts.peek().is_none() {
621 object.insert(part.to_owned(), value);
622 return;
623 }
624 current = object.entry(part).or_insert_with(|| json!({}));
625 }
626}
627
628fn remove_json_path(target: &mut Value, path: &str) {
629 let (parent, last) = match path.rsplit_once('.') {
630 Some((parent, last)) => (Some(parent), last),
631 None => (None, path),
632 };
633 let parent = match parent {
634 Some(parent) => parent
635 .split('.')
636 .try_fold(&mut *target, |value, part| value.get_mut(part)),
637 None => Some(target),
638 };
639 if let Some(Value::Object(object)) = parent {
640 object.remove(last);
641 }
642}
643
644struct Resolver<'a> {
645 workspace: &'a Workspace,
646 snapshot: &'a Snapshot,
647 selected: BTreeSet<String>,
648 values: BTreeMap<String, Value>,
649 named_stack: Vec<(String, String)>,
650 expanded: BTreeSet<(String, String)>,
652 external: BTreeSet<String>,
653 prepared: Option<&'a PreparedTask>,
656 cancelled: &'a AtomicBool,
657}
658
659impl Resolver<'_> {
660 fn input(&mut self, project: &str, input: &Value) -> Result<()> {
661 match input {
662 Value::String(name) if name.starts_with('^') => {
665 self.dependencies_named(project, &name[1..])?;
666 }
667 Value::Object(object)
669 if object.contains_key("input") && !object.contains_key("fileset") =>
670 {
671 let name = object["input"]
672 .as_str()
673 .context("input must name a named input")?;
674 let projects = object.get("projects");
675 let dependencies = object.get("dependencies") == Some(&json!(true))
676 || projects == Some(&json!("dependencies"));
677 let listed = projects.filter(|projects| {
678 **projects != json!("self") && **projects != json!("dependencies")
679 });
680 if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
681 bail!("named inputs can only refer to named inputs of their own project");
682 }
683 if dependencies {
684 self.dependencies_named(project, name)?;
685 } else if let Some(listed) = listed {
686 let patterns: Vec<String> = match listed {
687 Value::String(pattern) => vec![pattern.clone()],
688 other => serde_json::from_value(other.clone())
689 .context("input projects must be a string or an array")?,
690 };
691 for selected in
692 qk_graph::select_projects(&self.workspace.projects, &patterns, &[])?
693 {
694 if self.expanded.insert((selected.clone(), name.to_owned())) {
695 self.named(&selected, name)?;
696 }
697 }
698 } else {
699 self.named(project, name)?;
700 }
701 }
702 Value::Object(object)
703 if object.contains_key("fileset")
704 && object.get("dependencies") == Some(&json!(true)) =>
705 {
706 if !self.named_stack.is_empty() {
707 bail!("named inputs can only refer to named inputs of their own project");
708 }
709 let pattern = object["fileset"]
710 .as_str()
711 .context("fileset input must be a glob")?
712 .to_owned();
713 for dependency in self.dependency_closure(project) {
714 self.fileset(&dependency, &pattern)?;
715 }
716 }
717 Value::Object(object)
718 if object.len() == 1 && object.contains_key("workingDirectory") =>
719 {
720 let mode = object["workingDirectory"].as_str().unwrap_or_default();
721 let directory = std::env::current_dir()?;
722 let value = match mode {
723 "absolute" => directory.to_string_lossy().into_owned(),
724 "relative" => {
725 let root = self.workspace.root.canonicalize()?;
726 let directory = directory.canonicalize()?;
727 paths::relative(&root, &directory)
728 .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
729 }
730 _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
731 };
732 self.values
733 .insert(format!("workingDirectory:{mode}"), json!(value));
734 }
735 Value::Object(object) if object.contains_key("json") => {
736 self.json(project, object)?;
737 }
738 Value::String(name)
739 if !name.contains("{projectRoot}") && !name.contains("{workspaceRoot}") =>
740 {
741 self.named(project, name)?
742 }
743 Value::Object(object) if object.len() == 1 && object.contains_key("fileset") => {
744 let pattern = object["fileset"]
745 .as_str()
746 .context("fileset input must be a glob")?;
747 self.fileset(project, pattern)?;
748 }
749 Value::String(pattern) => self.fileset(project, pattern)?,
750 Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
751 let name = object["env"]
752 .as_str()
753 .context("env input must name a variable")?;
754 let Some(prepared) = self.prepared else {
755 self.values.insert(format!("env:{name}"), Value::Null);
756 return Ok(());
757 };
758 let value = prepared
759 .env
760 .get(std::ffi::OsStr::new(name))
761 .map(|value| {
762 value
763 .to_str()
764 .map(str::to_owned)
765 .context("declared env input must be UTF-8")
766 })
767 .transpose()?;
768 self.values.insert(format!("env:{name}"), json!(value));
769 }
770 Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
771 let command = object["runtime"]
772 .as_str()
773 .context("runtime input must be a command")?;
774 let Some(prepared) = self.prepared else {
775 self.values
776 .insert(format!("runtime:{command}"), Value::Null);
777 return Ok(());
778 };
779 let memo = serde_json::to_string(&(
780 command,
781 prepared
782 .env
783 .iter()
784 .map(|(name, value)| (name.to_string_lossy(), value.to_string_lossy()))
785 .collect::<Vec<_>>(),
786 ))?;
787 if let Some(value) = self.snapshot.runtime.lock().unwrap().get(&memo) {
788 self.values
789 .insert(format!("runtime:{command}"), value.clone());
790 return Ok(());
791 }
792 let mut prepared = prepared.clone();
793 prepared.commands = vec![command.into()];
794 prepared.cwd = self.workspace.root.clone();
795 prepared.parallel = false;
796 let log = tempfile::NamedTempFile::new()?;
797 let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
798 if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
799 {
800 bail!("runtime input did not succeed");
801 }
802 let mut stdout = blake3::Hasher::new();
803 let mut stderr = blake3::Hasher::new();
804 read_capture(File::open(log.path())?, |error, bytes| {
805 if error {
806 stderr.update(bytes);
807 } else {
808 stdout.update(bytes);
809 }
810 Ok(())
811 })?;
812 let value = json!([
813 stdout.finalize().to_hex().to_string(),
814 stderr.finalize().to_hex().to_string()
815 ]);
816 self.snapshot
817 .runtime
818 .lock()
819 .unwrap()
820 .insert(memo, value.clone());
821 self.values.insert(format!("runtime:{command}"), value);
822 }
823 Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
824 if object
825 .keys()
826 .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
827 {
828 bail!("unsupported output input field");
829 }
830 let pattern = object["dependentTasksOutputFiles"]
831 .as_str()
832 .context("dependentTasksOutputFiles must be a glob")?;
833 self.snapshot.pattern(pattern, false)?;
834 let transitive = object
835 .get("transitive")
836 .map(|value| value.as_bool().context("transitive must be boolean"))
837 .transpose()?
838 .unwrap_or(false);
839 let value = self
843 .values
844 .entry(format!("dependentTasksOutputFiles:{pattern}"))
845 .or_insert(json!(false));
846 if transitive {
847 *value = json!(true);
848 }
849 }
850 Value::Object(object)
851 if object.len() == 1 && object.contains_key("externalDependencies") =>
852 {
853 let names: Vec<String> =
854 serde_json::from_value(object["externalDependencies"].clone())?;
855 self.external.extend(names);
856 }
857 _ => bail!("unsupported task input declaration"),
858 }
859 Ok(())
860 }
861
862 fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
863 let (exclude, pattern) = pattern
864 .strip_prefix('!')
865 .map(|pattern| (true, pattern))
866 .unwrap_or((false, pattern));
867 let pattern = paths::expand(self.workspace, project, pattern)?;
868 let matcher = self.snapshot.pattern(&pattern, exclude)?;
869 let prefix = pattern
871 .split('/')
872 .take_while(|part| is_literal(part))
873 .collect::<Vec<_>>()
874 .join("/");
875 let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
876 Box::new(self.snapshot.files.iter())
877 } else {
878 Box::new(under(&self.snapshot.files, &prefix))
879 };
880 for file in candidates.filter(|file| matcher.is_match(file)) {
881 if exclude {
882 self.selected.remove(file);
883 } else {
884 self.selected.insert(file.clone());
885 }
886 }
887 Ok(())
888 }
889
890 fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
892 let mut closure = BTreeSet::new();
893 let mut pending = vec![project.to_owned()];
894 while let Some(current) = pending.pop() {
895 for edge in &self.snapshot.projects.dependencies[¤t] {
896 if edge.target != project && closure.insert(edge.target.clone()) {
897 pending.push(edge.target.clone());
898 }
899 }
900 }
901 closure
902 }
903
904 fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
906 for dependency in self.dependency_closure(project) {
907 if self.expanded.insert((dependency.clone(), name.to_owned())) {
908 self.named(&dependency, name)?;
909 }
910 }
911 Ok(())
912 }
913
914 fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
918 for key in object.keys() {
919 if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
920 bail!("unsupported json input field {key:?}");
921 }
922 }
923 let path = object["json"]
924 .as_str()
925 .context("json input must name a file")?;
926 let path = paths::expand(self.workspace, project, path)?;
927 paths::validate_path(&path)?;
928 let list = |name: &str| -> Result<Option<Vec<String>>> {
929 object
930 .get(name)
931 .map(|value| {
932 serde_json::from_value(value.clone())
933 .with_context(|| format!("json input {name} must be an array of strings"))
934 })
935 .transpose()
936 };
937 let fields = list("fields")?;
938 let exclude = list("excludeFields")?.unwrap_or_default();
939 let value = match std::fs::read_to_string(self.workspace.root.join(&path)) {
940 Ok(text) => {
941 let mut value: Value =
942 jsonc_parser::parse_to_serde_value(&text, &Default::default())
943 .with_context(|| format!("cannot parse json input {path}"))?;
944 if let Some(fields) = &fields {
945 let mut kept = json!({});
946 for field in fields {
947 if let Some(found) = json_path(&value, field) {
948 set_json_path(&mut kept, field, found.clone());
949 }
950 }
951 value = kept;
952 }
953 for field in &exclude {
954 remove_json_path(&mut value, field);
955 }
956 value
957 }
958 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Null,
959 Err(error) => return Err(error.into()),
960 };
961 self.values.insert(format!("json:{path}"), value);
962 Ok(())
963 }
964
965 fn named(&mut self, project: &str, name: &str) -> Result<()> {
966 let identity = (project.to_owned(), name.to_owned());
967 if self.named_stack.contains(&identity) {
968 bail!("named input cycle at {project}:{name}");
969 }
970 self.named_stack.push(identity);
971 let inputs = self.workspace.projects[project]
972 .named_inputs
973 .get(name)
974 .cloned()
975 .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
976 .with_context(|| format!("unknown named input {name:?}"))?;
977 for input in &inputs {
978 self.input(project, input)?;
979 }
980 self.named_stack.pop();
981 Ok(())
982 }
983}
984
985pub struct Resolved {
989 pub files: BTreeSet<String>,
991 pub mandatory: BTreeSet<String>,
993 pub values: BTreeMap<String, Value>,
996 pub lockfile: Option<(BTreeSet<String>, BTreeSet<String>)>,
999 pub workspace_file: bool,
1002 pub external: BTreeSet<String>,
1004}
1005
1006pub fn resolve(
1007 snapshot: &Snapshot,
1008 workspace: &Workspace,
1009 task: &Task,
1010 prepared: Option<&PreparedTask>,
1011 cancelled: &AtomicBool,
1012) -> Result<Resolved> {
1013 let mut resolver = Resolver {
1014 workspace,
1015 snapshot,
1016 selected: BTreeSet::new(),
1017 values: BTreeMap::new(),
1018 named_stack: Vec::new(),
1019 expanded: BTreeSet::new(),
1020 external: BTreeSet::new(),
1021 prepared,
1022 cancelled,
1023 };
1024 let default = vec![json!("default"), json!("^default")];
1025 for input in task.definition.inputs.as_ref().unwrap_or(&default) {
1026 resolver.input(&task.project, input)?;
1027 }
1028 let readable = snapshot
1029 .installed(&workspace.root)?
1030 .is_some_and(|installed| installed.lockfile.is_some());
1031 let tsconfig = ["tsconfig.base.json", "tsconfig.json"]
1037 .into_iter()
1038 .find(|name| workspace.root.join(name).is_file());
1039 let mut mandatory = BTreeSet::new();
1040 for path in tsconfig.into_iter().chain([
1041 "nx.json",
1042 qk_config::LOCAL_WORKSPACE,
1043 "package.json",
1044 "pnpm-workspace.yaml",
1045 "pnpm-lock.yaml",
1046 "package-lock.json",
1047 "yarn.lock",
1048 "bun.lock",
1049 ]) {
1050 if workspace.root.join(path).is_file() && !(path == "pnpm-lock.yaml" && readable) {
1051 resolver.selected.insert(path.into());
1052 mandatory.insert(path.into());
1053 }
1054 }
1055 if let Some(extended) = &workspace.extended {
1056 resolver.selected.insert(extended.clone());
1057 mandatory.insert(extended.clone());
1058 }
1059 let mut packages = BTreeSet::from([task.project.clone()]);
1061 let mut pending = packages.clone();
1062 while let Some(project) = pending.pop_first() {
1063 for edge in &snapshot.projects.dependencies[&project] {
1064 if packages.insert(edge.target.clone()) {
1065 pending.insert(edge.target.clone());
1066 }
1067 }
1068 }
1069 for project in &packages {
1070 for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
1071 let path = Path::new(&workspace.projects[project].root).join(name);
1072 let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
1073 if workspace.root.join(&path).is_file() {
1074 let path = path
1075 .to_str()
1076 .context("project path must be UTF-8")?
1077 .replace('\\', "/");
1078 resolver.selected.insert(path.clone());
1079 mandatory.insert(path);
1080 }
1081 }
1082 }
1083 let workspace_file = readable && resolver.selected.remove("pnpm-workspace.yaml");
1086 if workspace_file {
1087 mandatory.remove("pnpm-workspace.yaml");
1088 }
1089 let lockfile = readable.then(|| {
1090 let importers = std::iter::once(".".to_owned())
1093 .chain(
1094 packages
1095 .iter()
1096 .map(|project| workspace.projects[project].root.clone()),
1097 )
1098 .collect();
1099 (importers, resolver.external.clone())
1100 });
1101 Ok(Resolved {
1102 files: resolver.selected,
1103 mandatory,
1104 values: resolver.values,
1105 lockfile,
1106 workspace_file,
1107 external: resolver.external,
1108 })
1109}
1110
1111pub fn inputs(
1114 snapshot: &Snapshot,
1115 workspace: &Workspace,
1116 task: &Task,
1117 prepared: &PreparedTask,
1118 dependencies: &BTreeMap<String, String>,
1119 cancelled: &AtomicBool,
1120) -> Result<Value> {
1121 let Resolved {
1122 files,
1123 mut values,
1124 lockfile,
1125 workspace_file,
1126 ..
1127 } = resolve(snapshot, workspace, task, Some(prepared), cancelled)?;
1128 let installed = snapshot.installed(&workspace.root)?;
1129 if let (Some((importers, external)), Some(installed)) = (lockfile, &installed)
1130 && let Some(lockfile) = &installed.lockfile
1131 {
1132 let importers: BTreeMap<_, _> = importers
1133 .iter()
1134 .filter(|importer| lockfile.has_importer(importer))
1135 .map(|importer| {
1136 let digest = installed.digest(&format!("importer:{importer}"), || {
1137 lockfile.installed(importer).unwrap_or_default()
1138 });
1139 (importer.clone(), digest)
1140 })
1141 .collect();
1142 let external: BTreeMap<_, _> = external
1143 .iter()
1144 .map(|name| {
1145 let digest =
1146 installed.digest(&format!("package:{name}"), || lockfile.package(name));
1147 (name.clone(), digest)
1148 })
1149 .collect();
1150 values.insert(
1151 "lockfile".into(),
1152 json!({
1153 "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
1154 "importers": importers,
1155 "external": external,
1156 }),
1157 );
1158 }
1159 let mut files = files
1160 .iter()
1161 .map(|path| {
1162 let value = snapshot
1163 .file_value(&workspace.root, path)
1164 .with_context(|| format!("cannot read input {path}"))?;
1165 Ok((path.clone(), value))
1166 })
1167 .collect::<Result<BTreeMap<_, _>>>()?;
1168 if workspace_file {
1169 match workspace_without_resolution(&workspace.root) {
1170 Some(value) => {
1171 values.insert("pnpm-workspace".into(), value);
1172 }
1173 None => {
1174 files.insert(
1175 "pnpm-workspace.yaml".into(),
1176 snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
1177 );
1178 }
1179 }
1180 }
1181 let mut definition = task.definition.clone();
1183 definition.extra.remove("qk:warm");
1184 definition.extra.remove("qk:threads");
1185 let hash = json!({
1186 "schema":"qk-local-v1", "qk":env!("CARGO_PKG_VERSION"),
1187 "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
1188 "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
1189 "files":files, "values":values, "dependencies":dependencies,
1190 });
1191 Ok(hash)
1192}
1193
1194pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
1197 let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
1198 without_resolution(&text)
1199}
1200
1201pub fn without_resolution(text: &str) -> Option<Value> {
1203 let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
1204 if let Some(object) = value.as_object_mut() {
1205 object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
1206 }
1207 Some(value)
1208}
1209
1210pub fn key(inputs: &Value) -> Result<String> {
1212 Ok(blake3::hash(&serde_json::to_vec(inputs)?)
1213 .to_hex()
1214 .to_string())
1215}
1216
1217pub fn fingerprint(
1218 snapshot: &Snapshot,
1219 workspace: &Workspace,
1220 task: &Task,
1221 prepared: &PreparedTask,
1222 dependencies: &BTreeMap<String, String>,
1223 cancelled: &AtomicBool,
1224) -> Result<String> {
1225 key(&inputs(
1226 snapshot,
1227 workspace,
1228 task,
1229 prepared,
1230 dependencies,
1231 cancelled,
1232 )?)
1233}
1234
1235pub(crate) fn dependency_keys(
1239 snapshot: &Snapshot,
1240 workspace: &Workspace,
1241 graph: &TaskGraph,
1242 task: &Task,
1243 dependencies: &BTreeMap<String, String>,
1244 cancelled: &AtomicBool,
1245) -> Result<BTreeMap<String, String>> {
1246 let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
1247 let selections: Vec<_> = resolved
1248 .values
1249 .iter()
1250 .filter_map(|(name, transitive)| {
1251 name.strip_prefix("dependentTasksOutputFiles:")
1252 .map(|pattern| (pattern, transitive == &json!(true)))
1253 })
1254 .collect();
1255 if selections.is_empty() {
1256 return Ok(dependencies.clone());
1257 }
1258 let mut keys = BTreeMap::new();
1259 for (pattern, transitive) in selections {
1260 let matcher = snapshot.pattern(pattern, false)?;
1261 let mut selected = task.dependencies.clone();
1262 if transitive {
1263 let mut pending = selected.clone();
1264 while let Some(id) = pending.pop_first() {
1265 for dependency in &graph.tasks[&id].dependencies {
1266 if selected.insert(dependency.clone()) {
1267 pending.insert(dependency.clone());
1268 }
1269 }
1270 }
1271 }
1272 for id in selected {
1273 let outputs = snapshot.dependency_outputs(workspace, &graph.tasks[&id])?;
1274 let mut files = BTreeMap::new();
1275 for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
1276 let known = outputs.values.lock().unwrap().get(path).cloned();
1277 let value = match known {
1278 Some(value) => value,
1279 None => {
1280 let value = snapshot.file_value(&workspace.root, path)?;
1281 if value.get("link").is_none() {
1283 outputs
1284 .values
1285 .lock()
1286 .unwrap()
1287 .insert(path.clone(), value.clone());
1288 }
1289 value
1290 }
1291 };
1292 files.insert(path.clone(), value);
1293 }
1294 keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
1295 }
1296 }
1297 Ok(keys)
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303 use qk_taskgraph::Request;
1304
1305 fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
1306 let root = tempfile::tempdir().unwrap();
1307 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1308 let mut post = json!({"command": "true"});
1309 if let Some(outputs) = outputs {
1310 post["outputs"] = outputs;
1311 }
1312 std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
1313 "gen": {"command":"true", "outputs":["generated"]},
1314 "post": post,
1315 "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
1316 }}).to_string()).unwrap();
1317 std::fs::create_dir(root.path().join("generated")).unwrap();
1318 std::fs::write(root.path().join("generated/value"), "one").unwrap();
1319 let workspace = Workspace::load(root.path()).unwrap();
1320 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1321 (root, workspace, graph)
1322 }
1323
1324 #[test]
1325 fn undeclared_writes_invalidate_dependency_values() {
1326 for outputs in [None, Some(json!(["generated"]))] {
1327 let (root, workspace, graph) = fixture(outputs);
1328 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1329 let keys = || {
1330 dependency_keys(
1331 &snapshot,
1332 &workspace,
1333 &graph,
1334 &graph.tasks["app:build"],
1335 &BTreeMap::new(),
1336 &AtomicBool::new(false),
1337 )
1338 .unwrap()
1339 };
1340 let before = keys();
1341 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1342 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1343 assert_ne!(before, keys());
1344 }
1345 }
1346
1347 #[cfg(unix)]
1348 #[test]
1349 fn dependency_symlinks_recheck_targets_outside_output_roots() {
1350 let (root, mut workspace, _) = fixture(Some(json!(["generated"])));
1351 workspace
1352 .projects
1353 .get_mut("app")
1354 .unwrap()
1355 .targets
1356 .get_mut("gen")
1357 .unwrap()
1358 .outputs = Some(vec!["links".into()]);
1359 std::fs::create_dir(root.path().join("links")).unwrap();
1360 std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
1361 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1362 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1363 let keys = || {
1364 dependency_keys(
1365 &snapshot,
1366 &workspace,
1367 &graph,
1368 &graph.tasks["app:build"],
1369 &BTreeMap::new(),
1370 &AtomicBool::new(false),
1371 )
1372 .unwrap()
1373 };
1374 let before = keys();
1375 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1376 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1377 assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
1378 }
1379}