1use std::collections::{BTreeMap, BTreeSet};
6use std::path::{Component, Path, PathBuf};
7use std::sync::atomic::AtomicBool;
8
9use anyhow::{Context, Result};
10use qk_config::Workspace;
11use qk_lockfile::Lockfile;
12use qk_taskgraph::TaskGraph;
13use serde_json::Value;
14
15use crate::{hash, paths};
16
17pub struct Resolved {
19 pub files: BTreeSet<String>,
21 pub mandatory: BTreeSet<String>,
23 pub values: BTreeMap<String, Value>,
26 pub lockfile: Option<(BTreeSet<String>, BTreeSet<String>)>,
29 pub workspace_file: bool,
32 pub external: BTreeSet<String>,
34}
35
36impl Resolved {
37 pub fn file_inputs(&self) -> impl Iterator<Item = &str> {
39 self.files
40 .iter()
41 .map(String::as_str)
42 .chain(self.json_inputs())
43 }
44
45 pub fn json_inputs(&self) -> impl Iterator<Item = &str> {
46 self.value_names("json:")
47 }
48
49 pub fn manifest_inputs(&self) -> impl Iterator<Item = &str> {
52 self.value_names("manifest:")
53 }
54
55 pub fn is_json_input(&self, path: &str) -> bool {
56 self.values.contains_key(&format!("json:{path}"))
57 }
58
59 pub fn environment_inputs(&self) -> impl Iterator<Item = &str> {
60 self.value_names("env:")
61 }
62
63 pub fn runtime_inputs(&self) -> impl Iterator<Item = &str> {
64 self.value_names("runtime:")
65 }
66
67 pub fn dependency_output_inputs(&self) -> impl Iterator<Item = (&str, bool)> {
69 self.values.iter().filter_map(|(name, transitive)| {
70 name.strip_prefix("dependentTasksOutputFiles:")
71 .map(|pattern| (pattern, transitive == &Value::Bool(true)))
72 })
73 }
74
75 pub fn is_resolution_metadata(&self, path: &str) -> bool {
77 (path == "pnpm-lock.yaml" && self.lockfile.is_some())
78 || (path == "pnpm-workspace.yaml" && self.workspace_file)
79 }
80
81 fn value_names<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a str> {
82 self.values
83 .keys()
84 .filter_map(move |name| name.strip_prefix(prefix))
85 }
86}
87
88pub fn resolve_tasks(
92 workspace: &Workspace,
93 graph: &TaskGraph,
94 extra: &[String],
95) -> Result<BTreeMap<String, Resolved>> {
96 let cache = paths::cache_location(workspace);
97 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
98 let cancelled = AtomicBool::new(false);
99 graph
100 .tasks
101 .iter()
102 .map(|(id, task)| {
103 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
104 .with_context(|| format!("cannot resolve the inputs of {id}"))?;
105 Ok((id.clone(), resolved))
106 })
107 .collect()
108}
109
110pub struct Resolution {
113 pub candidates: std::collections::BTreeSet<String>,
114 pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
115}
116
117pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
118 let cache = paths::cache_location(workspace);
119 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
120 let cancelled = AtomicBool::new(false);
121 let tasks = graph
122 .tasks
123 .iter()
124 .map(|(id, task)| {
125 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
126 .map_err(|error| format!("{error:#}"));
127 (id.clone(), resolved)
128 })
129 .collect();
130 Ok(Resolution {
131 candidates: snapshot.files.clone(),
132 tasks,
133 })
134}
135
136#[derive(Debug, PartialEq, Eq)]
138pub enum InputChange {
139 Input { file: String },
140 Installs { importer: String },
141 Package { name: String },
142 Lockfile,
143 WorkspaceFile,
144}
145
146pub struct InputChanges<'a> {
150 root: PathBuf,
151 files: &'a [String],
152 changed: BTreeSet<&'a str>,
153 lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
154 workspace_file_changed: bool,
155 manifests: BTreeMap<String, (Option<String>, Option<String>)>,
157 links: BTreeMap<String, Vec<String>>,
158 directory_links: BTreeMap<PathBuf, Vec<PathBuf>>,
159}
160
161impl<'a> InputChanges<'a> {
162 pub fn new(
166 root: &Path,
167 files: &'a [String],
168 lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
169 workspace_file_changed: bool,
170 ) -> Result<Self> {
171 Ok(Self {
172 root: root.canonicalize()?,
173 files,
174 changed: files.iter().map(String::as_str).collect(),
175 lockfiles,
176 workspace_file_changed,
177 manifests: BTreeMap::new(),
178 links: BTreeMap::new(),
179 directory_links: BTreeMap::new(),
180 })
181 }
182
183 pub fn with_manifests(
187 mut self,
188 manifests: BTreeMap<String, (Option<String>, Option<String>)>,
189 ) -> Self {
190 self.manifests = manifests;
191 self
192 }
193
194 pub fn reasons(&mut self, inputs: &Resolved) -> Result<Vec<InputChange>> {
197 let mut reasons = Vec::new();
198 let mut touched = BTreeSet::new();
199 for file in inputs.file_inputs() {
200 if self.changed.contains(file) {
201 touched.insert(file);
202 continue;
203 }
204 if !self.links.contains_key(file) {
205 self.links.insert(
206 file.to_owned(),
207 symlink_targets(&self.root, file, &mut self.directory_links)?,
208 );
209 }
210 for target in &self.links[file] {
211 touched.extend(
212 self.files
213 .iter()
214 .filter(|changed| {
215 target.is_empty()
216 || **changed == *target
217 || changed
218 .strip_prefix(target)
219 .is_some_and(|suffix| suffix.starts_with('/'))
220 })
221 .map(String::as_str),
222 );
223 }
224 }
225 let lockfile = inputs.lockfile.is_some();
226 for file in inputs.manifest_inputs() {
227 if !self.changed.contains(file) {
228 continue;
229 }
230 let unchanged = self.manifests.get(file).is_some_and(|(before, after)| {
231 let read = |text: &Option<String>| {
232 text.as_deref()
233 .and_then(|text| manifest_for_dependents(text, lockfile))
234 };
235 let before = read(before);
236 before.is_some() && before == read(after)
237 });
238 if !unchanged {
239 touched.insert(file);
240 }
241 }
242 for file in touched {
243 reasons.push(InputChange::Input {
244 file: file.to_owned(),
245 });
246 }
247 if inputs.workspace_file && self.workspace_file_changed {
248 reasons.push(InputChange::WorkspaceFile);
249 }
250 match (&inputs.lockfile, &self.lockfiles) {
251 (Some((importers, external)), Some((before, after))) => {
252 if before.global() != after.global() {
253 reasons.push(InputChange::Lockfile);
254 }
255 for importer in importers {
256 if before.installed(importer) != after.installed(importer) {
257 reasons.push(InputChange::Installs {
258 importer: importer.clone(),
259 });
260 }
261 }
262 for name in external {
263 if before.package(name) != after.package(name) {
264 reasons.push(InputChange::Package { name: name.clone() });
265 }
266 }
267 }
268 (Some(_), None) if self.changed.contains("pnpm-lock.yaml") => {
269 reasons.push(InputChange::Input {
270 file: "pnpm-lock.yaml".into(),
271 });
272 }
273 _ => {}
274 }
275 Ok(reasons)
276 }
277}
278
279const DEPENDENT_IGNORED_FIELDS: &[&str] = &[
285 "version",
286 "scripts",
287 "description",
288 "keywords",
289 "author",
290 "contributors",
291 "maintainers",
292 "license",
293 "homepage",
294 "repository",
295 "bugs",
296 "funding",
297 "private",
298 "publishConfig",
299];
300
301const DECLARATION_FIELDS: &[&str] = &[
304 "dependencies",
305 "devDependencies",
306 "peerDependencies",
307 "peerDependenciesMeta",
308 "optionalDependencies",
309 "dependenciesMeta",
310];
311
312pub fn manifest_for_dependents(text: &str, lockfile: bool) -> Option<Value> {
317 let mut value: Value = serde_json::from_str(text).ok()?;
318 let object = value.as_object_mut()?;
319 object.retain(|key, _| {
320 !DEPENDENT_IGNORED_FIELDS.contains(&key.as_str())
321 && !(lockfile && DECLARATION_FIELDS.contains(&key.as_str()))
322 });
323 Some(value)
324}
325
326pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
329 let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
330 without_resolution(&text)
331}
332
333pub fn without_resolution(text: &str) -> Option<Value> {
335 let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
336 if let Some(object) = value.as_object_mut() {
337 object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
338 }
339 Some(value)
340}
341
342fn symlink_targets(
344 root: &Path,
345 path: &str,
346 directory_links: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
347) -> Result<Vec<String>> {
348 let mut pending = vec![root.join(path)];
349 let mut visited = BTreeSet::new();
350 let mut targets = BTreeSet::new();
351 while let Some(absolute) = pending.pop() {
352 if !visited.insert(absolute.clone()) {
353 continue;
354 }
355 let metadata = match std::fs::symlink_metadata(&absolute) {
356 Ok(metadata) => metadata,
357 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
358 Err(error) => return Err(error.into()),
359 };
360 if !metadata.file_type().is_symlink() {
361 continue;
362 }
363 let target = absolute
364 .parent()
365 .expect("input has a parent")
366 .join(std::fs::read_link(&absolute)?);
367 let mut normalized = PathBuf::new();
368 for component in target.components() {
369 match component {
370 Component::CurDir => {}
371 Component::ParentDir => {
372 normalized.pop();
373 }
374 component => normalized.push(component.as_os_str()),
375 }
376 }
377 let resolved = target.canonicalize().ok().or_else(|| {
378 Some(
379 target
380 .parent()?
381 .canonicalize()
382 .ok()?
383 .join(target.file_name()?),
384 )
385 });
386 if normalized.starts_with(root) {
387 pending.push(normalized.clone());
388 }
389 if let Some(resolved) = &resolved
390 && resolved.starts_with(root)
391 && resolved.is_dir()
392 {
393 if !directory_links.contains_key(resolved) {
394 let mut links = Vec::new();
395 for entry in walkdir::WalkDir::new(resolved).follow_links(false) {
396 let entry = entry?;
397 if entry.file_type().is_symlink() {
398 links.push(entry.into_path());
399 }
400 }
401 directory_links.insert(resolved.clone(), links);
402 }
403 pending.extend(directory_links[resolved].iter().cloned());
404 }
405 for target in std::iter::once(normalized).chain(resolved) {
406 if let Ok(relative) = target.strip_prefix(root)
407 && let Some(relative) = relative.to_str()
408 {
409 targets.insert(relative.replace('\\', "/"));
410 }
411 }
412 }
413 Ok(targets.into_iter().collect())
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use qk_taskgraph::Request;
420 use serde_json::json;
421
422 fn workspace(
423 inputs: Value,
424 lockfile: Option<&str>,
425 ) -> (tempfile::TempDir, Workspace, TaskGraph) {
426 let root = tempfile::tempdir().unwrap();
427 std::fs::create_dir(root.path().join("app")).unwrap();
428 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
429 std::fs::write(
430 root.path().join("app/project.json"),
431 json!({
432 "name": "app", "targets": {"build": {"command": "echo build", "inputs": inputs}}
433 })
434 .to_string(),
435 )
436 .unwrap();
437 std::fs::write(root.path().join("app/meta.json"), r#"{"version":1}"#).unwrap();
438 if let Some(lockfile) = lockfile {
439 std::fs::write(root.path().join("pnpm-lock.yaml"), lockfile).unwrap();
440 std::fs::write(
441 root.path().join("pnpm-workspace.yaml"),
442 "packages:\n - app\n",
443 )
444 .unwrap();
445 }
446 let workspace = Workspace::load(root.path()).unwrap();
447 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
448 (root, workspace, graph)
449 }
450
451 #[test]
452 fn dependency_manifests_change_by_what_they_decide_for_dependents() {
453 let root = tempfile::tempdir().unwrap();
454 let path = "lib/package.json";
455 let inputs = |lockfile: bool| Resolved {
456 files: BTreeSet::new(),
457 mandatory: BTreeSet::from([path.to_owned()]),
458 values: BTreeMap::from([(format!("manifest:{path}"), Value::Null)]),
459 lockfile: lockfile.then(|| (BTreeSet::new(), BTreeSet::new())),
460 workspace_file: false,
461 external: BTreeSet::new(),
462 };
463 let changed = vec![path.to_owned()];
464 let reasons = |before: Option<&str>, after: Option<&str>, lockfile: bool| {
465 InputChanges::new(root.path(), &changed, None, false)
466 .unwrap()
467 .with_manifests(BTreeMap::from([(
468 path.to_owned(),
469 (before.map(String::from), after.map(String::from)),
470 )]))
471 .reasons(&inputs(lockfile))
472 .unwrap()
473 };
474 let input = [InputChange::Input { file: path.into() }];
475 let base = r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^1"}}"#;
476 assert!(
477 reasons(
478 Some(base),
479 Some(r#"{"name":"lib","version":"2.0.0","dependencies":{"a":"^1"}}"#),
480 false
481 )
482 .is_empty()
483 );
484 assert_eq!(
485 reasons(
486 Some(base),
487 Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
488 false
489 ),
490 input
491 );
492 assert!(
493 reasons(
494 Some(base),
495 Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
496 true
497 )
498 .is_empty()
499 );
500 assert_eq!(
501 reasons(Some(base), Some(r#"{"name":"other"}"#), true),
502 input
503 );
504 assert_eq!(reasons(None, Some(base), true), input);
506 assert_eq!(reasons(Some(base), None, true), input);
507 assert_eq!(reasons(Some("{"), Some("{"), true), input);
508 assert_eq!(
510 InputChanges::new(root.path(), &changed, None, false)
511 .unwrap()
512 .reasons(&inputs(true))
513 .unwrap(),
514 input
515 );
516 }
517
518 #[test]
519 fn shared_declarations_do_not_execute_runtime_inputs_or_read_environment_values() {
520 let (root, workspace, graph) = workspace(
521 json!([
522 {"json": "{projectRoot}/meta.json", "fields": ["version"]},
523 {"env": "PATH"}, {"runtime": "echo evaluated > runtime-marker"},
524 {"dependentTasksOutputFiles": "**/*.js", "transitive": true}
525 ]),
526 None,
527 );
528 let changed = vec!["runtime-marker".into(), "app/meta.json".into()];
529 let resolved = resolve_tasks(&workspace, &graph, &changed).unwrap();
530 let inputs = &resolved["app:build"];
531 assert_eq!(inputs.json_inputs().collect::<Vec<_>>(), ["app/meta.json"]);
532 assert_eq!(inputs.environment_inputs().collect::<Vec<_>>(), ["PATH"]);
533 assert_eq!(
534 inputs.runtime_inputs().collect::<Vec<_>>(),
535 ["echo evaluated > runtime-marker"]
536 );
537 assert_eq!(inputs.values["env:PATH"], Value::Null);
538 assert_eq!(
539 inputs.values["runtime:echo evaluated > runtime-marker"],
540 Value::Null
541 );
542 assert_eq!(
543 inputs.dependency_output_inputs().collect::<Vec<_>>(),
544 [("**/*.js", true)]
545 );
546 assert!(inputs.file_inputs().any(|path| path == "app/meta.json"));
547 assert_eq!(
548 InputChanges::new(root.path(), &changed, None, false)
549 .unwrap()
550 .reasons(inputs)
551 .unwrap(),
552 [InputChange::Input {
553 file: "app/meta.json".into()
554 }]
555 );
556 assert!(!root.path().join("runtime-marker").exists());
557 let each = resolve_each(&workspace, &graph).unwrap();
558 assert_eq!(
559 each.tasks["app:build"].as_ref().unwrap().values,
560 inputs.values
561 );
562 assert!(!root.path().join("runtime-marker").exists());
563 }
564
565 fn lockfile(app: &str, other: &str) -> String {
566 let versions = BTreeSet::from([app, other]);
567 let packages: String = versions
568 .iter()
569 .map(|version| {
570 format!(" react@{version}:\n resolution: {{integrity: sha512-{version}}}\n")
571 })
572 .collect();
573 let snapshots: String = versions
574 .iter()
575 .map(|version| format!(" react@{version}: {{}}\n"))
576 .collect();
577 format!(
578 "lockfileVersion: '9.0'\nimporters:\n .: {{}}\n app:\n dependencies:\n react:\n specifier: '*'\n version: {app}\n other:\n dependencies:\n react:\n specifier: '*'\n version: {other}\npackages:\n{packages}snapshots:\n{snapshots}"
579 )
580 }
581
582 #[test]
583 fn installation_changes_preserve_selectivity_fallback_and_reason_order() {
584 let before_text = lockfile("1.0.0", "1.0.0");
585 let (root, workspace, graph) = workspace(
586 json!([
587 {"json": "{projectRoot}/meta.json"}, {"externalDependencies": ["react"]}
588 ]),
589 Some(&before_text),
590 );
591 let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
592 let inputs = &resolved["app:build"];
593 assert!(inputs.is_resolution_metadata("pnpm-lock.yaml"));
594 assert!(inputs.is_resolution_metadata("pnpm-workspace.yaml"));
595 let before = Lockfile::parse(&before_text).unwrap();
596 let unrelated = Lockfile::parse(&lockfile("1.0.0", "2.0.0")).unwrap();
597 let changed = vec![
598 "pnpm-workspace.yaml".into(),
599 "pnpm-lock.yaml".into(),
600 "app/meta.json".into(),
601 ];
602 let mut changes =
603 InputChanges::new(root.path(), &changed, Some((&before, &unrelated)), true).unwrap();
604 assert_eq!(
607 changes.reasons(inputs).unwrap(),
608 [
609 InputChange::Input {
610 file: "app/meta.json".into()
611 },
612 InputChange::WorkspaceFile,
613 InputChange::Package {
614 name: "react".into()
615 },
616 ]
617 );
618 let after = Lockfile::parse(
619 &(lockfile("2.0.0", "2.0.0") + "settings:\n autoInstallPeers: false\n"),
620 )
621 .unwrap();
622 assert_eq!(
623 InputChanges::new(root.path(), &changed, Some((&before, &after)), true)
624 .unwrap()
625 .reasons(inputs)
626 .unwrap(),
627 [
628 InputChange::Input {
629 file: "app/meta.json".into()
630 },
631 InputChange::WorkspaceFile,
632 InputChange::Lockfile,
633 InputChange::Installs {
634 importer: "app".into()
635 },
636 InputChange::Package {
637 name: "react".into()
638 },
639 ]
640 );
641 assert_eq!(
642 InputChanges::new(root.path(), &changed, None, false)
643 .unwrap()
644 .reasons(inputs)
645 .unwrap(),
646 [
647 InputChange::Input {
648 file: "app/meta.json".into()
649 },
650 InputChange::Input {
651 file: "pnpm-lock.yaml".into()
652 },
653 ]
654 );
655 }
656}