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 {
20 pub declared: Declared,
21 pub inferred: Inferred,
22}
23
24#[derive(Default)]
27pub struct Declared {
28 pub files: BTreeSet<String>,
30 pub ignored: BTreeSet<String>,
33 pub values: BTreeMap<String, Value>,
36 pub json_fields: BTreeMap<String, Vec<JsonFields>>,
39 pub external: BTreeSet<String>,
41 pub external_declared: bool,
44 pub projects: BTreeSet<String>,
49 pub producers: BTreeSet<String>,
53 pub selection: hash::Selection,
55}
56
57#[derive(Default)]
61pub struct Inferred {
62 pub files: BTreeSet<String>,
65 pub settings: BTreeSet<String>,
70 pub project: BTreeSet<String>,
74 pub workspace_file: bool,
77 pub lockfile: Option<BTreeSet<String>>,
79 pub lockfile_readable: bool,
82 pub manifests: BTreeMap<String, Value>,
85 pub tsconfig: Option<String>,
89 pub covered: BTreeSet<String>,
91}
92
93#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
95pub struct JsonFields {
96 pub fields: Option<Vec<String>>,
98 pub exclude: Vec<String>,
100}
101
102impl Inferred {
103 pub fn paths(&self) -> impl Iterator<Item = &str> {
105 self.files
106 .iter()
107 .chain(&self.settings)
108 .chain(&self.project)
109 .map(String::as_str)
110 .chain(self.workspace_file.then_some("pnpm-workspace.yaml"))
111 .chain(self.lockfile.is_some().then_some("pnpm-lock.yaml"))
112 .chain(self.manifests.keys().map(String::as_str))
113 .chain(self.tsconfig.as_deref())
114 }
115
116 pub fn is_resolution_metadata(&self, path: &str) -> bool {
119 (path == "pnpm-lock.yaml" && self.lockfile.is_some())
120 || (path == "pnpm-workspace.yaml" && self.workspace_file)
121 }
122}
123
124pub const ROOT_TSCONFIG_FILES: [&str; 2] = ["tsconfig.base.json", "tsconfig.json"];
126
127pub fn selectively_hash_tsconfig(workspace: &Workspace) -> bool {
131 let runners = workspace.config.extra.get("tasksRunnerOptions");
132 let runner = std::env::var("NX_TASKS_RUNNER")
133 .ok()
134 .filter(|runner| runners.is_some_and(|runners| runners.get(runner).is_some()))
135 .unwrap_or_else(|| "default".into());
136 runners
137 .and_then(|runners| runners.get(&runner))
138 .and_then(|runner| runner.get("options"))
139 .and_then(|options| options.get("selectivelyHashTsConfig"))
140 .and_then(Value::as_bool)
141 .unwrap_or(false)
142}
143
144pub struct TsConfig {
148 pub rest: Value,
149 pub paths: BTreeMap<String, Vec<String>>,
150}
151
152impl TsConfig {
153 pub fn parse(text: &str) -> Option<Self> {
154 let mut rest: Value = jsonc_parser::parse_to_serde_value(text, &Default::default()).ok()?;
155 let mut paths = BTreeMap::new();
156 if let Some(options) = rest
157 .as_object_mut()?
158 .get_mut("compilerOptions")
159 .and_then(Value::as_object_mut)
160 && let Some(removed) = options.remove("paths")
161 {
162 paths = serde_json::from_value(removed).ok()?;
163 }
164 Some(Self { rest, paths })
165 }
166
167 pub fn paths_of<'a>(
171 &self,
172 projects: &BTreeSet<String>,
173 owner: impl Fn(&str) -> Option<&'a str>,
174 ) -> BTreeMap<String, Vec<String>> {
175 self.paths
176 .iter()
177 .filter_map(|(key, targets)| {
178 let owned: Vec<String> = targets
179 .iter()
180 .filter(|target| owner(target).is_some_and(|owner| projects.contains(owner)))
181 .cloned()
182 .collect();
183 (!owned.is_empty()).then(|| (key.clone(), owned))
184 })
185 .collect()
186 }
187}
188
189impl Resolved {
190 pub fn whole_files(&self) -> impl Iterator<Item = &str> {
192 self.declared
193 .files
194 .iter()
195 .chain(&self.inferred.files)
196 .map(String::as_str)
197 }
198
199 pub fn is_whole_file(&self, path: &str) -> bool {
201 self.declared.files.contains(path) || self.inferred.files.contains(path)
202 }
203
204 pub fn file_inputs(&self) -> impl Iterator<Item = &str> {
207 self.whole_files().chain(self.json_inputs())
208 }
209
210 pub fn always_included(&self) -> BTreeSet<String> {
213 self.inferred
214 .paths()
215 .map(str::to_owned)
216 .chain(self.inferred.covered.iter().cloned())
217 .collect()
218 }
219
220 pub fn selects_alike(&self, other: &Resolved, workspace: &Workspace) -> bool {
230 let (one, two) = (&self.declared, &other.declared);
231 let (mine, theirs) = (&self.inferred, &other.inferred);
232 one.files == two.files
233 && one.ignored == two.ignored
234 && one.values == two.values
235 && one.external == two.external
236 && one.producers == two.producers
237 && (!selectively_hash_tsconfig(workspace) || one.projects == two.projects)
238 && mine.files == theirs.files
239 && mine.manifests.keys().eq(theirs.manifests.keys())
240 && mine.lockfile == theirs.lockfile
241 && mine.tsconfig == theirs.tsconfig
242 && mine.workspace_file == theirs.workspace_file
243 }
244
245 pub fn json_inputs(&self) -> impl Iterator<Item = &str> {
246 self.value_names("json:")
247 }
248
249 pub fn is_json_input(&self, path: &str) -> bool {
250 self.declared.values.contains_key(&format!("json:{path}"))
251 }
252
253 pub fn environment_inputs(&self) -> impl Iterator<Item = &str> {
254 self.value_names("env:")
255 }
256
257 pub fn runtime_inputs(&self) -> impl Iterator<Item = &str> {
258 self.value_names("runtime:")
259 }
260
261 pub fn dependency_output_inputs(&self) -> impl Iterator<Item = (&str, bool)> {
263 self.declared
264 .values
265 .iter()
266 .filter_map(|(name, transitive)| {
267 name.strip_prefix("dependentTasksOutputFiles:")
268 .map(|pattern| (pattern, transitive == &Value::Bool(true)))
269 })
270 }
271
272 fn value_names<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a str> {
273 self.declared
274 .values
275 .keys()
276 .filter_map(move |name| name.strip_prefix(prefix))
277 }
278}
279
280pub fn resolve_tasks(
284 workspace: &Workspace,
285 graph: &TaskGraph,
286 extra: &[String],
287) -> Result<BTreeMap<String, Resolved>> {
288 let cache = paths::cache_location(workspace);
289 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
290 let cancelled = AtomicBool::new(false);
291 graph
292 .tasks
293 .iter()
294 .map(|(id, task)| {
295 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
296 .with_context(|| format!("cannot resolve the inputs of {id}"))?;
297 Ok((id.clone(), resolved))
298 })
299 .collect()
300}
301
302pub struct Resolution {
305 pub candidates: std::collections::BTreeSet<String>,
306 pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
307}
308
309pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
310 let cache = paths::cache_location(workspace);
311 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
312 let cancelled = AtomicBool::new(false);
313 let tasks = graph
314 .tasks
315 .iter()
316 .map(|(id, task)| {
317 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
318 .map_err(|error| format!("{error:#}"));
319 (id.clone(), resolved)
320 })
321 .collect();
322 Ok(Resolution {
323 candidates: snapshot.files.clone(),
324 tasks,
325 })
326}
327
328#[derive(Debug, PartialEq, Eq)]
330pub enum InputChange {
331 Input { file: String },
332 Installs { importer: String },
333 Package { name: String },
334 Lockfile,
335 WorkspaceFile,
336}
337
338pub struct InputChanges<'a> {
342 root: PathBuf,
343 files: &'a [String],
344 changed: BTreeSet<&'a str>,
345 lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
346 workspace_file_changed: bool,
347 manifests: BTreeMap<String, (Option<String>, Option<String>)>,
349 json: BTreeMap<String, (Option<String>, Option<String>)>,
351 links: BTreeMap<String, Vec<String>>,
352 directory_links: BTreeMap<PathBuf, Vec<PathBuf>>,
353}
354
355impl<'a> InputChanges<'a> {
356 pub fn new(
360 root: &Path,
361 files: &'a [String],
362 lockfiles: Option<(&'a Lockfile, &'a Lockfile)>,
363 workspace_file_changed: bool,
364 ) -> Result<Self> {
365 Ok(Self {
366 root: root.canonicalize()?,
367 files,
368 changed: files.iter().map(String::as_str).collect(),
369 lockfiles,
370 workspace_file_changed,
371 manifests: BTreeMap::new(),
372 json: BTreeMap::new(),
373 links: BTreeMap::new(),
374 directory_links: BTreeMap::new(),
375 })
376 }
377
378 pub fn with_manifests(
382 mut self,
383 manifests: BTreeMap<String, (Option<String>, Option<String>)>,
384 ) -> Self {
385 self.manifests = manifests;
386 self
387 }
388
389 pub fn with_json(mut self, json: BTreeMap<String, (Option<String>, Option<String>)>) -> Self {
393 self.json = json;
394 self
395 }
396
397 pub fn reasons(&mut self, inputs: &Resolved) -> Result<Vec<InputChange>> {
400 let mut reasons = Vec::new();
401 let mut touched = BTreeSet::new();
402 for file in inputs.file_inputs() {
403 if self.changed.contains(file) {
404 if inputs.is_whole_file(file) || !self.json_unchanged(inputs, file) {
406 touched.insert(file);
407 }
408 continue;
409 }
410 if !self.links.contains_key(file) {
411 self.links.insert(
412 file.to_owned(),
413 symlink_targets(&self.root, file, &mut self.directory_links)?,
414 );
415 }
416 for target in &self.links[file] {
417 touched.extend(
418 self.files
419 .iter()
420 .filter(|changed| {
421 target.is_empty()
422 || **changed == *target
423 || changed
424 .strip_prefix(target)
425 .is_some_and(|suffix| suffix.starts_with('/'))
426 })
427 .map(String::as_str),
428 );
429 }
430 }
431 let lockfile = inputs.inferred.lockfile_readable;
432 for file in inputs.inferred.manifests.keys().map(String::as_str) {
433 if !self.changed.contains(file) {
434 continue;
435 }
436 let unchanged = self.manifests.get(file).is_some_and(|(before, after)| {
437 let read = |text: &Option<String>| {
438 text.as_deref()
439 .and_then(|text| manifest_for_dependents(text, lockfile))
440 };
441 let before = read(before);
442 before.is_some() && before == read(after)
443 });
444 if !unchanged {
445 touched.insert(file);
446 }
447 }
448 for file in touched {
449 reasons.push(InputChange::Input {
450 file: file.to_owned(),
451 });
452 }
453 if inputs.inferred.workspace_file && self.workspace_file_changed {
454 reasons.push(InputChange::WorkspaceFile);
455 }
456 let Some(importers) = &inputs.inferred.lockfile else {
458 return Ok(reasons);
459 };
460 match &self.lockfiles {
461 Some((before, after)) => {
462 if before.global() != after.global() {
463 reasons.push(InputChange::Lockfile);
464 }
465 for importer in importers {
466 if before.installed(importer) != after.installed(importer) {
467 reasons.push(InputChange::Installs {
468 importer: importer.clone(),
469 });
470 }
471 }
472 for name in &inputs.declared.external {
473 if before.package(name) != after.package(name) {
474 reasons.push(InputChange::Package { name: name.clone() });
475 }
476 }
477 }
478 None if self.changed.contains("pnpm-lock.yaml") => {
479 reasons.push(InputChange::Input {
480 file: "pnpm-lock.yaml".into(),
481 });
482 }
483 None => {}
484 }
485 Ok(reasons)
486 }
487}
488
489impl InputChanges<'_> {
490 fn json_unchanged(&self, inputs: &Resolved, file: &str) -> bool {
493 let (Some(selections), Some((before, after))) =
494 (inputs.declared.json_fields.get(file), self.json.get(file))
495 else {
496 return false;
497 };
498 selections.iter().all(|fields| {
499 let read = |text: &Option<String>| {
500 text.as_deref()
501 .and_then(|text| hash::json_projection(file, text, fields))
502 };
503 let before = read(before);
504 before.is_some() && before == read(after)
505 })
506 }
507}
508
509const DEPENDENT_IGNORED_FIELDS: &[&str] = &[
515 "version",
516 "scripts",
517 "description",
518 "keywords",
519 "author",
520 "contributors",
521 "maintainers",
522 "license",
523 "homepage",
524 "repository",
525 "bugs",
526 "funding",
527 "private",
528 "publishConfig",
529];
530
531const DECLARATION_FIELDS: &[&str] = &[
534 "dependencies",
535 "devDependencies",
536 "peerDependencies",
537 "peerDependenciesMeta",
538 "optionalDependencies",
539 "dependenciesMeta",
540];
541
542pub fn manifest_for_dependents(text: &str, lockfile: bool) -> Option<Value> {
547 let mut value: Value = serde_json::from_str(text).ok()?;
548 let object = value.as_object_mut()?;
549 object.retain(|key, _| {
550 !DEPENDENT_IGNORED_FIELDS.contains(&key.as_str())
551 && !(lockfile && DECLARATION_FIELDS.contains(&key.as_str()))
552 });
553 Some(value)
554}
555
556pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
559 let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
560 without_resolution(&text)
561}
562
563pub fn without_resolution(text: &str) -> Option<Value> {
565 let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
566 if let Some(object) = value.as_object_mut() {
567 object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
568 }
569 Some(value)
570}
571
572fn symlink_targets(
574 root: &Path,
575 path: &str,
576 directory_links: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
577) -> Result<Vec<String>> {
578 let mut pending = vec![root.join(path)];
579 let mut visited = BTreeSet::new();
580 let mut targets = BTreeSet::new();
581 while let Some(absolute) = pending.pop() {
582 if !visited.insert(absolute.clone()) {
583 continue;
584 }
585 let metadata = match std::fs::symlink_metadata(&absolute) {
586 Ok(metadata) => metadata,
587 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
588 Err(error) => return Err(error.into()),
589 };
590 if !metadata.file_type().is_symlink() {
591 continue;
592 }
593 let target = absolute
594 .parent()
595 .expect("input has a parent")
596 .join(std::fs::read_link(&absolute)?);
597 let mut normalized = PathBuf::new();
598 for component in target.components() {
599 match component {
600 Component::CurDir => {}
601 Component::ParentDir => {
602 normalized.pop();
603 }
604 component => normalized.push(component.as_os_str()),
605 }
606 }
607 let resolved = target.canonicalize().ok().or_else(|| {
608 Some(
609 target
610 .parent()?
611 .canonicalize()
612 .ok()?
613 .join(target.file_name()?),
614 )
615 });
616 if normalized.starts_with(root) {
617 pending.push(normalized.clone());
618 }
619 if let Some(resolved) = &resolved
620 && resolved.starts_with(root)
621 && resolved.is_dir()
622 {
623 if !directory_links.contains_key(resolved) {
624 let mut links = Vec::new();
625 for entry in walkdir::WalkDir::new(resolved).follow_links(false) {
626 let entry = entry?;
627 if entry.file_type().is_symlink() {
628 links.push(entry.into_path());
629 }
630 }
631 directory_links.insert(resolved.clone(), links);
632 }
633 pending.extend(directory_links[resolved].iter().cloned());
634 }
635 for target in std::iter::once(normalized).chain(resolved) {
636 if let Ok(relative) = target.strip_prefix(root)
637 && let Some(relative) = relative.to_str()
638 {
639 targets.insert(relative.replace('\\', "/"));
640 }
641 }
642 }
643 Ok(targets.into_iter().collect())
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use qk_taskgraph::Request;
650 use serde_json::json;
651
652 fn workspace(
653 inputs: Value,
654 lockfile: Option<&str>,
655 ) -> (tempfile::TempDir, Workspace, TaskGraph) {
656 let root = tempfile::tempdir().unwrap();
657 std::fs::create_dir(root.path().join("app")).unwrap();
658 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
659 std::fs::write(
660 root.path().join("app/project.json"),
661 json!({
662 "name": "app", "targets": {"build": {"command": "echo build", "inputs": inputs}}
663 })
664 .to_string(),
665 )
666 .unwrap();
667 std::fs::write(root.path().join("app/meta.json"), r#"{"version":1}"#).unwrap();
668 if let Some(lockfile) = lockfile {
669 std::fs::write(root.path().join("pnpm-lock.yaml"), lockfile).unwrap();
670 std::fs::write(
671 root.path().join("pnpm-workspace.yaml"),
672 "packages:\n - app\n",
673 )
674 .unwrap();
675 }
676 let workspace = Workspace::load(root.path()).unwrap();
677 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
678 (root, workspace, graph)
679 }
680
681 fn declared(inputs: Value) -> Result<BTreeSet<String>> {
684 declared_with(inputs, &[])
685 }
686
687 fn declared_with(inputs: Value, extra: &[&str]) -> Result<BTreeSet<String>> {
688 let root = tempfile::tempdir().unwrap();
689 for file in extra {
690 let path = root.path().join(file);
691 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
692 std::fs::write(path, "").unwrap();
693 }
694 std::fs::write(root.path().join(".gitignore"), "dist/\n").unwrap();
695 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
696 for (path, text) in [
697 (
698 "app/project.json",
699 json!({"name": "app", "implicitDependencies": ["lib"],
700 "namedInputs": {"production": ["{projectRoot}/src/**/*"]},
701 "targets": {"build": {"command": "true", "inputs": inputs}}})
702 .to_string(),
703 ),
704 ("lib/project.json", json!({"name": "lib"}).to_string()),
705 ("app/src/main.ts", String::new()),
706 ("app/src/app/(group)/page.ts", String::new()),
707 ("app/src/app/group/page.ts", String::new()),
708 ("app/app/notes.md", String::new()),
709 ("app/workspaceRoot/x.ts", String::new()),
710 ("lib/src/index.ts", String::new()),
711 ("lib/lib.ts", String::new()),
712 ("tools/gen.ts", String::new()),
713 ] {
714 let path = root.path().join(path);
715 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
716 std::fs::write(path, text).unwrap();
717 }
718 let workspace = Workspace::load(root.path()).unwrap();
719 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
720 let mut resolved = resolve_tasks(&workspace, &graph, &[])?;
721 Ok(resolved.remove("app:build").unwrap().declared.files)
722 }
723
724 fn files(paths: &[&str]) -> BTreeSet<String> {
725 paths.iter().map(|path| (*path).to_owned()).collect()
726 }
727
728 #[test]
729 fn dependency_filesets_are_written_with_a_caret() {
730 assert_eq!(
731 declared(json!([
732 "^{projectRoot}/src/**/*",
733 "^{workspaceRoot}/tools/**/*"
734 ]))
735 .unwrap(),
736 files(&["lib/src/index.ts", "tools/gen.ts"])
737 );
738 assert!(declared(json!([{"fileset": "^{projectRoot}/src/**/*"}])).is_err());
740 }
741
742 #[test]
743 fn filesets_read_the_project_name_and_workspace_root_only_at_the_start() {
744 assert_eq!(
745 declared(json!(["{workspaceRoot}/{projectName}/src/main.ts"])).unwrap(),
746 files(&["app/src/main.ts"])
747 );
748 assert_eq!(
750 declared(json!(["{projectRoot}/{workspaceRoot}/x.ts"])).unwrap(),
751 files(&["app/workspaceRoot/x.ts"])
752 );
753 assert!(declared(json!(["src/**/*"])).is_err());
755 assert_eq!(declared(json!(["production"])).unwrap().len(), 3);
756 }
757
758 #[test]
759 fn a_fileset_naming_a_directory_selects_everything_under_it() {
760 let src = files(&[
761 "app/src/main.ts",
762 "app/src/app/(group)/page.ts",
763 "app/src/app/group/page.ts",
764 ]);
765 for fileset in [
766 "{projectRoot}/src",
767 "{projectRoot}/src/",
768 "{projectRoot}/src/**/*",
769 ] {
770 assert_eq!(declared(json!([fileset])).unwrap(), src, "{fileset}");
771 }
772 assert_eq!(
773 declared(json!(["{projectRoot}/**/*", "!{projectRoot}/src"])).unwrap(),
774 files(&[
775 "app/app/notes.md",
776 "app/project.json",
777 "app/workspaceRoot/x.ts"
778 ])
779 );
780 }
781
782 #[test]
783 fn a_backslash_escapes_a_fileset_character() {
784 assert_eq!(
785 declared(json!([r"{projectRoot}/src/app/\(group\)/**/*"])).unwrap(),
786 files(&["app/src/app/(group)/page.ts"])
787 );
788 assert_eq!(
789 declared(json!([r"{projectRoot}/src/app/\(group\)"])).unwrap(),
790 files(&["app/src/app/(group)/page.ts"])
791 );
792 }
793
794 #[test]
795 fn json_inputs_can_select_several_files() {
796 let root = tempfile::tempdir().unwrap();
797 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
798 for (path, text) in [
799 (
800 "app/project.json",
801 json!({"name": "app", "targets": {"build": {"command": "true",
802 "inputs": [{"json": "{projectRoot}/config/*.json", "fields": ["a"]},
803 {"json": "{workspaceRoot}/shared/**/*.json"}]}}})
804 .to_string(),
805 ),
806 ("app/config/one.json", r#"{"a": 1, "b": 2}"#.to_owned()),
807 ("app/config/two.json", r#"{"a": 3}"#.to_owned()),
808 ("app/config/notes.txt", String::new()),
809 (
810 "app/config/nested/project.json",
811 json!({"name": "nested"}).to_string(),
812 ),
813 ("app/config/nested/x.json", "{}".to_owned()),
814 ("shared/deep/s.json", r#"{"s": true}"#.to_owned()),
815 ] {
816 let path = root.path().join(path);
817 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
818 std::fs::write(path, text).unwrap();
819 }
820 let workspace = Workspace::load(root.path()).unwrap();
821 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
822 let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
823 let declared = &resolved["app:build"].declared;
824 assert_eq!(
825 declared.values,
826 BTreeMap::from([
827 ("json:app/config/one.json".to_owned(), json!({"a": 1})),
828 ("json:app/config/two.json".to_owned(), json!({"a": 3})),
829 ("json:shared/deep/s.json".to_owned(), json!({"s": true})),
830 ])
831 );
832 }
833
834 #[test]
835 fn include_ignored_groups_filter_their_own_positives() {
836 let dist = [
837 "lib/dist/index.js",
838 "lib/dist/index.js.map",
839 "app/dist/main.js",
840 ];
841 let dependencies = |fileset: &str| json!({"fileset": fileset, "includeIgnored": true, "dependencies": true});
842 assert_eq!(
843 declared_with(
844 json!([
845 dependencies("{projectRoot}/dist/**"),
846 dependencies("!{projectRoot}/dist/**/*.map"),
847 {"fileset": "{projectRoot}/dist", "includeIgnored": true},
848 ]),
849 &dist
850 )
851 .unwrap(),
852 files(&["app/dist/main.js", "lib/dist/index.js"])
853 );
854 let error = declared_with(
856 json!([
857 dependencies("{projectRoot}/dist/**"),
858 {"fileset": "!{projectRoot}/dist/**/*.map", "includeIgnored": true},
859 ]),
860 &dist,
861 )
862 .unwrap_err();
863 assert!(
864 format!("{error:#}").contains("no positive includeIgnored fileset"),
865 "{error:#}"
866 );
867 assert!(
869 declared_with(json!(["{projectRoot}/dist/**"]), &dist)
870 .unwrap()
871 .is_empty()
872 );
873 }
874
875 #[test]
876 fn always_inputs_must_select_a_project() {
877 let error =
878 declared(json!([{"input": "default", "projects": ["lib", "missing"], "always": true}]))
879 .unwrap_err();
880 assert!(
881 format!("{error:#}").contains("\"missing\", which matches no project"),
882 "{error:#}"
883 );
884 assert!(declared(json!([{"input": "default", "projects": ["missing"]}])).is_ok());
885 }
886
887 #[test]
888 fn external_dependencies_cannot_name_a_project() {
889 let error = declared(json!([{"externalDependencies": ["lib"]}])).unwrap_err();
890 assert!(
891 format!("{error:#}").contains("already a dependency"),
892 "{error:#}"
893 );
894 }
895
896 #[test]
897 fn declared_inputs_cover_the_files_qk_would_add() {
898 let (_root, loaded, graph) = workspace(
899 json!([
900 "{projectRoot}/meta.json",
901 "{workspaceRoot}/nx.json",
902 "{workspaceRoot}/pnpm-lock.yaml"
903 ]),
904 Some(&lockfile("1.0.0", "1.0.0")),
905 );
906 let resolved = resolve_tasks(&loaded, &graph, &[]).unwrap();
907 let inputs = &resolved["app:build"];
908 assert_eq!(
910 inputs.declared.files,
911 BTreeSet::from(["app/meta.json", "nx.json", "pnpm-lock.yaml"].map(String::from))
912 );
913 assert!(!inputs.inferred.files.contains("nx.json"));
916 assert!(inputs.inferred.settings.contains("nx.json"));
917 assert!(inputs.inferred.project.contains("app/project.json"));
918 assert_eq!(inputs.inferred.lockfile, None);
919 assert_eq!(
920 inputs.inferred.covered,
921 BTreeSet::from(["pnpm-lock.yaml"].map(String::from))
922 );
923 assert!(inputs.always_included().contains("nx.json"));
924 let (_root, loaded, graph) = workspace(
926 json!(["{projectRoot}/meta.json"]),
927 Some(&lockfile("1.0.0", "1.0.0")),
928 );
929 let resolved = resolve_tasks(&loaded, &graph, &[]).unwrap();
930 let inputs = &resolved["app:build"];
931 assert!(!inputs.declared.files.contains("pnpm-lock.yaml"));
932 assert!(inputs.inferred.lockfile.is_some());
933 assert!(inputs.inferred.covered.is_empty());
934 }
935
936 #[test]
937 fn dependency_manifests_change_by_what_they_decide_for_dependents() {
938 let root = tempfile::tempdir().unwrap();
939 let path = "lib/package.json";
940 let inputs = |lockfile: bool| Resolved {
941 declared: Declared::default(),
942 inferred: Inferred {
943 manifests: BTreeMap::from([(path.to_owned(), Value::Null)]),
944 lockfile: lockfile.then(BTreeSet::new),
945 lockfile_readable: lockfile,
946 ..Inferred::default()
947 },
948 };
949 let changed = vec![path.to_owned()];
950 let reasons = |before: Option<&str>, after: Option<&str>, lockfile: bool| {
951 InputChanges::new(root.path(), &changed, None, false)
952 .unwrap()
953 .with_manifests(BTreeMap::from([(
954 path.to_owned(),
955 (before.map(String::from), after.map(String::from)),
956 )]))
957 .reasons(&inputs(lockfile))
958 .unwrap()
959 };
960 let input = [InputChange::Input { file: path.into() }];
961 let base = r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^1"}}"#;
962 assert!(
963 reasons(
964 Some(base),
965 Some(r#"{"name":"lib","version":"2.0.0","dependencies":{"a":"^1"}}"#),
966 false
967 )
968 .is_empty()
969 );
970 assert_eq!(
971 reasons(
972 Some(base),
973 Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
974 false
975 ),
976 input
977 );
978 assert!(
979 reasons(
980 Some(base),
981 Some(r#"{"name":"lib","version":"1.0.0","dependencies":{"a":"^2"}}"#),
982 true
983 )
984 .is_empty()
985 );
986 assert_eq!(
987 reasons(Some(base), Some(r#"{"name":"other"}"#), true),
988 input
989 );
990 assert_eq!(reasons(None, Some(base), true), input);
992 assert_eq!(reasons(Some(base), None, true), input);
993 assert_eq!(reasons(Some("{"), Some("{"), true), input);
994 assert_eq!(
996 InputChanges::new(root.path(), &changed, None, false)
997 .unwrap()
998 .reasons(&inputs(true))
999 .unwrap(),
1000 input
1001 );
1002 }
1003
1004 #[test]
1005 fn shared_declarations_do_not_execute_runtime_inputs_or_read_environment_values() {
1006 let (root, workspace, graph) = workspace(
1007 json!([
1008 {"json": "{projectRoot}/meta.json", "fields": ["version"]},
1009 {"env": "PATH"}, {"runtime": "echo evaluated > runtime-marker"},
1010 {"dependentTasksOutputFiles": "**/*.js", "transitive": true}
1011 ]),
1012 None,
1013 );
1014 let changed = vec!["runtime-marker".into(), "app/meta.json".into()];
1015 let resolved = resolve_tasks(&workspace, &graph, &changed).unwrap();
1016 let inputs = &resolved["app:build"];
1017 assert_eq!(inputs.json_inputs().collect::<Vec<_>>(), ["app/meta.json"]);
1018 assert_eq!(inputs.environment_inputs().collect::<Vec<_>>(), ["PATH"]);
1019 assert_eq!(
1020 inputs.runtime_inputs().collect::<Vec<_>>(),
1021 ["echo evaluated > runtime-marker"]
1022 );
1023 assert_eq!(inputs.declared.values["env:PATH"], Value::Null);
1024 assert_eq!(
1025 inputs.declared.values["runtime:echo evaluated > runtime-marker"],
1026 Value::Null
1027 );
1028 assert_eq!(
1029 inputs.dependency_output_inputs().collect::<Vec<_>>(),
1030 [("**/*.js", true)]
1031 );
1032 assert!(inputs.file_inputs().any(|path| path == "app/meta.json"));
1033 assert_eq!(
1034 InputChanges::new(root.path(), &changed, None, false)
1035 .unwrap()
1036 .reasons(inputs)
1037 .unwrap(),
1038 [InputChange::Input {
1039 file: "app/meta.json".into()
1040 }]
1041 );
1042 assert!(!root.path().join("runtime-marker").exists());
1043 let each = resolve_each(&workspace, &graph).unwrap();
1044 assert_eq!(
1045 each.tasks["app:build"].as_ref().unwrap().declared.values,
1046 inputs.declared.values
1047 );
1048 assert!(!root.path().join("runtime-marker").exists());
1049 }
1050
1051 fn lockfile(app: &str, other: &str) -> String {
1052 let versions = BTreeSet::from([app, other]);
1053 let packages: String = versions
1054 .iter()
1055 .map(|version| {
1056 format!(" react@{version}:\n resolution: {{integrity: sha512-{version}}}\n")
1057 })
1058 .collect();
1059 let snapshots: String = versions
1060 .iter()
1061 .map(|version| format!(" react@{version}: {{}}\n"))
1062 .collect();
1063 format!(
1064 "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}"
1065 )
1066 }
1067
1068 #[test]
1069 fn installation_changes_preserve_selectivity_fallback_and_reason_order() {
1070 let before_text = lockfile("1.0.0", "1.0.0");
1071 let (root, workspace, graph) = workspace(
1072 json!([
1073 {"json": "{projectRoot}/meta.json"}, {"externalDependencies": ["react"]}
1074 ]),
1075 Some(&before_text),
1076 );
1077 let resolved = resolve_tasks(&workspace, &graph, &[]).unwrap();
1078 let inputs = &resolved["app:build"];
1079 assert!(inputs.inferred.is_resolution_metadata("pnpm-lock.yaml"));
1080 assert!(
1081 inputs
1082 .inferred
1083 .is_resolution_metadata("pnpm-workspace.yaml")
1084 );
1085 let before = Lockfile::parse(&before_text).unwrap();
1086 let unrelated = Lockfile::parse(&lockfile("1.0.0", "2.0.0")).unwrap();
1087 let changed = vec![
1088 "pnpm-workspace.yaml".into(),
1089 "pnpm-lock.yaml".into(),
1090 "app/meta.json".into(),
1091 ];
1092 let mut changes =
1093 InputChanges::new(root.path(), &changed, Some((&before, &unrelated)), true).unwrap();
1094 assert_eq!(
1097 changes.reasons(inputs).unwrap(),
1098 [
1099 InputChange::Input {
1100 file: "app/meta.json".into()
1101 },
1102 InputChange::WorkspaceFile,
1103 InputChange::Package {
1104 name: "react".into()
1105 },
1106 ]
1107 );
1108 let after = Lockfile::parse(
1109 &(lockfile("2.0.0", "2.0.0") + "settings:\n autoInstallPeers: false\n"),
1110 )
1111 .unwrap();
1112 assert_eq!(
1113 InputChanges::new(root.path(), &changed, Some((&before, &after)), true)
1114 .unwrap()
1115 .reasons(inputs)
1116 .unwrap(),
1117 [
1118 InputChange::Input {
1119 file: "app/meta.json".into()
1120 },
1121 InputChange::WorkspaceFile,
1122 InputChange::Lockfile,
1123 InputChange::Installs {
1124 importer: "app".into()
1125 },
1126 InputChange::Package {
1127 name: "react".into()
1128 },
1129 ]
1130 );
1131 assert_eq!(
1132 InputChanges::new(root.path(), &changed, None, false)
1133 .unwrap()
1134 .reasons(inputs)
1135 .unwrap(),
1136 [
1137 InputChange::Input {
1138 file: "app/meta.json".into()
1139 },
1140 InputChange::Input {
1141 file: "pnpm-lock.yaml".into()
1142 },
1143 ]
1144 );
1145 }
1146}