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