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